diff --git a/.github/workflows/lint-packages.yml b/.github/workflows/lint-packages.yml index a85a22e5..b3343af8 100644 --- a/.github/workflows/lint-packages.yml +++ b/.github/workflows/lint-packages.yml @@ -346,6 +346,69 @@ jobs: working-directory: packages/uipath-agent-framework run: uv run ruff format --check . + lint-claude-sdk: + name: Lint uipath-claude-sdk + needs: detect-changed-packages + runs-on: uipath-ubuntu-latest + steps: + - name: Check if package changed + id: check + run: | + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'test-core-dev-version') }}" == "true" ]]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "reason=custom-version-testing" >> $GITHUB_OUTPUT + elif echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-claude-sdk")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + echo "reason=no-changes" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + run: | + if [[ "${{ steps.check.outputs.reason }}" == "custom-version-testing" ]]; then + echo "Skipping - custom version testing enabled" + else + echo "Skipping - no changes to uipath-claude-sdk" + fi + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + enable-cache: true + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version-file: "packages/uipath-claude-sdk/.python-version" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-claude-sdk + run: uv sync --all-extras + + - name: Check static types + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-claude-sdk + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-claude-sdk + run: uv run ruff check . + + - name: Check formatting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-claude-sdk + run: uv run ruff format --check . + skip-lint: name: Skip Lint (Custom Version Testing) runs-on: uipath-ubuntu-latest diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 06eea8a7..5601e11c 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -405,11 +405,81 @@ jobs: path: packages/uipath-agent-framework/coverage.xml retention-days: 30 + test-claude-sdk: + name: Test (uipath-claude-sdk, ${{ matrix.python-version }}, ${{ matrix.os }}) + needs: detect-changed-packages + runs-on: ${{ matrix.os == 'windows-latest' && 'uipath-windows-latest' || 'uipath-ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-claude-sdk")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-claude-sdk" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-claude-sdk + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Run tests + if: steps.check.outputs.skip != 'true' && !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13') + working-directory: packages/uipath-claude-sdk + run: uv run pytest + + - name: Run tests with coverage + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + working-directory: packages/uipath-claude-sdk + run: uv run pytest --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage HTML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-html-uipath-claude-sdk + path: packages/uipath-claude-sdk/htmlcov/ + retention-days: 30 + + - name: Upload coverage XML report + if: steps.check.outputs.skip != 'true' && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-xml-uipath-claude-sdk + path: packages/uipath-claude-sdk/coverage.xml + retention-days: 30 + sonarcloud: name: SonarCloud - needs: [test-llamaindex, test-openai-agents, test-google-adk, test-pydantic-ai, test-agent-framework] + needs: [test-llamaindex, test-openai-agents, test-google-adk, test-pydantic-ai, test-agent-framework, test-claude-sdk] runs-on: uipath-ubuntu-latest - if: always() && needs.test-llamaindex.result != 'failure' && needs.test-openai-agents.result != 'failure' && needs.test-google-adk.result != 'failure' && needs.test-pydantic-ai.result != 'failure' && needs.test-agent-framework.result != 'failure' + if: always() && needs.test-llamaindex.result != 'failure' && needs.test-openai-agents.result != 'failure' && needs.test-google-adk.result != 'failure' && needs.test-pydantic-ai.result != 'failure' && needs.test-agent-framework.result != 'failure' && needs.test-claude-sdk.result != 'failure' permissions: contents: read steps: @@ -453,6 +523,13 @@ jobs: name: coverage-xml-uipath-agent-framework path: packages/uipath-agent-framework + - name: Download uipath-claude-sdk coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + continue-on-error: true + with: + name: coverage-xml-uipath-claude-sdk + path: packages/uipath-claude-sdk + - name: Rewrite coverage XML to repo-relative paths run: | sed -i 's|src/uipath_llamaindex|packages/uipath-llamaindex/src/uipath_llamaindex|g' packages/uipath-llamaindex/coverage.xml || true @@ -460,6 +537,7 @@ jobs: sed -i 's|src/uipath_google_adk|packages/uipath-google-adk/src/uipath_google_adk|g' packages/uipath-google-adk/coverage.xml || true sed -i 's|src/uipath_pydantic_ai|packages/uipath-pydantic-ai/src/uipath_pydantic_ai|g' packages/uipath-pydantic-ai/coverage.xml || true sed -i 's|src/uipath_agent_framework|packages/uipath-agent-framework/src/uipath_agent_framework|g' packages/uipath-agent-framework/coverage.xml || true + sed -i 's|src/uipath_claude_sdk|packages/uipath-claude-sdk/src/uipath_claude_sdk|g' packages/uipath-claude-sdk/coverage.xml || true - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 diff --git a/packages/uipath-claude-sdk/.python-version b/packages/uipath-claude-sdk/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/packages/uipath-claude-sdk/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/uipath-claude-sdk/CONTRIBUTING.md b/packages/uipath-claude-sdk/CONTRIBUTING.md new file mode 100644 index 00000000..32bec62c --- /dev/null +++ b/packages/uipath-claude-sdk/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to UiPath Claude Agent SDK + +## Local Development Setup + +### Prerequisites + +1. **Install Python 3.11 or later**: + - Download and install Python from the official [Python website](https://www.python.org/downloads/) + - Verify the installation by running: + ```sh + python3.11 --version + ``` + + Alternative: [mise](https://mise.jdx.dev/lang/python.html) + + The package is pinned to 3.11 through `.python-version` and is tested on 3.11, 3.12 and 3.13. + +2. **Install [uv](https://docs.astral.sh/uv/)**: + ```sh + pip install uv + ``` + +3. **Create a virtual environment in the current working directory**: + ```sh + uv venv + ``` + +4. **Install dependencies**: + ```sh + uv sync --all-extras + ``` + +### Checks + +Run these from `packages/uipath-claude-sdk`. They are the same commands CI runs: + +```sh +uv run mypy --config-file pyproject.toml . +uv run ruff check . +uv run ruff format --check . +uv run pytest +``` + +`uv run ruff format .` rewrites the files in place instead of only reporting. + +### Use SDK Locally +1. Create a folder on your own device `mkdir project; cd project` +2. Initialize the python project `uv` `uv init . --python 3.11` +3. Obtain the project path `PATH_TO_SDK=/Users/YOUR_USER/uipath-integrations-python/packages/uipath-claude-sdk/` +4. Install the sdk in editable mode `uv add --editable ${PATH_TO_SDK}` + +:information_source: Instead of cloning the project into `.venv/lib/python3.11/site-packages/uipath-claude-sdk`, this mode creates a file named `_uipath-claude-sdk.pth` inside `.venv/lib/python3.11/site-packages`. That file contains the value of `PATH_TO_SDK`, which is added to `sys.path`, the list of directories where python searches for packages. (Run `python -c 'import sys; print(sys.path)'` to see the entries.) diff --git a/packages/uipath-claude-sdk/README.md b/packages/uipath-claude-sdk/README.md new file mode 100644 index 00000000..a39cbac0 --- /dev/null +++ b/packages/uipath-claude-sdk/README.md @@ -0,0 +1,332 @@ +# UiPath Claude Agent SDK + +A Python SDK that enables developers to build and deploy [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) agents to the UiPath Cloud Platform. + +You define an agent in Python, point a `claude.json` file at it, and the UiPath runtime discovers it, generates its entry points, and executes it. Routing model calls through the UiPath LLM Gateway is an explicit opt-in, described under [Model routing](#model-routing). + +This package is an extension to the [UiPath Python SDK](https://github.com/UiPath/uipath-python) and implements the [UiPath Runtime Protocol](https://github.com/UiPath/uipath-runtime-python). + +Check out these [sample projects](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-claude-sdk/samples) to see the SDK in action. + +## Requirements + +- Python 3.11 or higher +- UiPath Automation Cloud account + +## Installation + +```bash +pip install uipath-claude-sdk +``` + +using `uv`: + +```bash +uv add uipath-claude-sdk +``` + +## Quickstart + +```bash +uipath auth # authenticate against UiPath +uipath new my-agent # scaffold main.py, claude.json and pyproject.toml +uipath init # generate uipath.json, entry-points.json, bindings.json and agent.mermaid +uipath run agent '{"city": "London"}' +``` + +`uipath new` writes into the current directory. The scaffolded agent is a weather agent with typed input and output, and its only input field is `city`, which is why the run command above passes `{"city": "London"}`: + +```python +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + + +@tool( + "get_weather", + "Get the current weather reading for a city, e.g. London.", + {"city": str}, +) +async def get_weather(args: dict) -> dict: + ... + + +weather_server = create_sdk_mcp_server( + name="weather", + tools=[get_weather], +) + + +class WeatherRequest(BaseModel): + city: str = Field(description="City to report the weather for, e.g. 'London'.") + + +class WeatherReport(BaseModel): + city: str = Field(description="City the reading refers to.") + temperature_celsius: float = Field(description="Temperature returned by the tool.") + summary: str = Field(description="One sentence description of the weather.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are a weather assistant. " + "Always call the get_weather tool before answering, and report only " + "the values it returns." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"weather": weather_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=WeatherRequest, + output_schema=WeatherReport, + prompt="Report the current weather in {city}.", +) +``` + +The `get_weather` tool is defined with `@tool` and served in process through `create_sdk_mcp_server`, which is how you attach custom Python tools to an agent without running an external MCP process. + +Anything the Claude Agent SDK accepts on `ClaudeAgentOptions` (system prompt, allowed tools, MCP servers, `output_format`, ...) is yours to set. The runtime only injects execution-scoped fields (`cwd`, `env`, `resume`, `model` when `uipath_llm` is set, and `output_format` when an `output_schema` is declared) and leaves the rest of your configuration untouched. + +An agent is what its code says. No tools, no MCP servers and no prompt text are added on your behalf, so an agent written for the Claude Agent SDK runs on UiPath unchanged, and an agent written here runs anywhere else. Everything UiPath-specific, model routing and human in the loop alike, is an explicit opt-in. + +## The agent definition + +`UiPathClaudeAgent` wraps the SDK options with the pieces the UiPath runtime needs: + +| Field | Purpose | +|-------|---------| +| `options` | The `ClaudeAgentOptions` passed to the Claude Agent SDK | +| `uipath_llm` | Optional `UiPathModel`. Set it to route model calls through the UiPath LLM Gateway. Left unset, the Claude Agent SDK reaches Anthropic on its own credentials | +| `input_schema` | Optional Pydantic model. Input is validated against it before the run | +| `output_schema` | Optional Pydantic model. The runtime requests native structured output and validates the result against it | +| `prompt` | Optional user-message template rendered with `str.format` from the validated input, for example `"Report the current weather in {city}"` | +| `name` | Display name used in the runtime graph | + +`ClaudeAgent` is kept as an alias of `UiPathClaudeAgent`, so existing agents keep working unchanged. + +The schemas are what `uipath init` writes into `entry-points.json`, so they also drive the input and output contract shown in the UiPath platform. + +Exporting a bare `ClaudeAgentOptions` also works. The loader wraps it in a `UiPathClaudeAgent` with defaults, in which case the entry point falls back to a single `input` string in and a single `result` string out, unless you set `options.output_format` yourself. An agent exported this way has no `uipath_llm`, so it runs on the direct Anthropic path. + +## Model routing + +`uipath_llm` decides where model traffic goes. There is no automatic fallback between the two paths. + +### With `uipath_llm` set + +```python +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions(system_prompt="..."), + uipath_llm=UiPathModel("claude-sonnet-4-5"), +) +``` + +`UiPathModel` is a routing descriptor, not a client. It holds no connection, opens no socket and is not callable. It records which model to use and how to route to it, and the runtime reads it when it builds the environment for the Claude Agent SDK subprocess. + +On this path the runtime starts a local gateway shim, binds a loopback listener, and injects `ANTHROPIC_BASE_URL` and a per-run secret so the SDK talks to the shim instead of Anthropic. The shim forwards every call to your tenant, which is why this path needs `UIPATH_URL` and `UIPATH_ACCESS_TOKEN` (see [Environment variables](#environment-variables)). + +Write a plain model id. The shim resolves it against the tenant's model discovery, so you do not need to know the id the tenant actually serves. It matches, in order: + +- the exact name discovery reported +- its lowercase form +- the same name with an `anthropic.` prefix stripped +- the same name with a `-YYYYMMDD-vN:N` version suffix or an `@version` suffix stripped +- the family words `opus`, `sonnet` and `haiku`, which resolve to the newest matching model + +`options.model` is overwritten with the resolved id on this path, so setting it alongside `uipath_llm` has no effect. The auxiliary models the Claude Code CLI picks for background work are pinned to catalog-resolved ids too, so no traffic escapes to a default model name the tenant does not host. + +`UiPathModel` takes the model name and nothing else. How to reach that model is discovery's to report, not yours to declare, which is what keeps a name that works in `uipath-langchain` working here. + +### Without `uipath_llm` + +No shim starts and no LLM environment is injected. The Claude Agent SDK reaches Anthropic on whatever credentials it finds for itself, so set `ANTHROPIC_API_KEY` and put a plain Anthropic model id on the options: + +```python +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions(model="claude-sonnet-4-5", system_prompt="..."), +) +``` + +Usage on this path is billed to your Anthropic account. + +## Human in the loop + +Suspending a run is opt-in and lives in your own tools. Nothing is injected: an agent that never calls `interrupt()` has no UiPath surface at all. + +Write an ordinary SDK tool, call `interrupt()` inside it with a model imported from `uipath.platform.common`, and register the tool through `uipath_tool_server`: + +```python +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, tool +from uipath.platform.common import CreateTask + +from uipath_claude_sdk import UiPathClaudeAgent, interrupt, uipath_tool_server + + +@tool( + "review_classification", + "Get a human to review the classification.", + {"ticket_id": str, "label": str}, +) +async def review_classification(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + app_name="escalation_agent_app", + title="Action Required: Review classification", + data={"AgentOutput": f"Classified {args['ticket_id']} as {args['label']}"}, + app_folder_path="Shared", + ) + ) + approved = action_data.get("Answer") is True + return {"content": [{"type": "text", "text": f"Approved: {approved}"}]} + + +tickets_server = uipath_tool_server("tickets", tools=[review_classification]) + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions(mcp_servers={"tickets": tickets_server}), +) +``` + +`uipath_tool_server` returns a plain `McpSdkServerConfig`. Register it under whatever key you like, and the model sees the tools as `mcp____`. Tools that never suspend belong in a normal `create_sdk_mcp_server`. + +### The value decides the trigger + +Whatever you pass to `interrupt()` reaches the platform untouched, and the platform picks the resume trigger from its type: + +| Value | Resume trigger | +|-------|----------------| +| `CreateTask`, `WaitTask`, `CreateEscalation`, `WaitEscalation` | Action Center task | +| `InvokeProcess`, `WaitJob` | Orchestrator job | +| `WaitIntegrationEvent` | Integration Services event | +| `WaitUntil` | Timer | +| Anything else, including a plain string | API trigger, answered by the resume input | + +A list or tuple becomes sibling triggers under one interrupt, resolved by whichever fires first. Every current and future model in `uipath.platform.common` works without any change in this package. + +`interrupt()` returns whatever the fired trigger carried back: the completed action's data, the finished job's output, the delivered event's payload, or the JSON passed to `uipath run agent ... --resume`. Mapping that onto the result the model reads is your tool's job. + +### The tool body runs twice + +A tool handler cannot pause across a process boundary, so the body is executed by a `PreToolUse` hook instead. On the suspend pass `interrupt()` raises, the hook parks the call and the run reports SUSPENDED. On resume the same body runs again in a fresh process, `interrupt()` returns the resolved payload, and the body's return value is delivered as the parked call's tool result. + +This is the same replay a LangGraph node does, with the same caveat: **work performed before the `interrupt()` call happens twice.** Keep side effects after the call, or make them idempotent. + +Two further constraints, both measured against the Claude Code CLI: + +- Only one interrupt can be in flight per run. A second suspending call while one is pending is denied and the model is told to retry it on its own. +- A suspending tool call must be the last tool call of its turn. Say so in the system prompt. + +Outside a UiPath run the tools simply execute their bodies, and `interrupt()` raises `InterruptOutsideRunError`. + +## Configuration + +### claude.json + +`claude.json` maps an entry-point name to the module attribute holding the agent definition, in `file.py:variable` form: + +```json +{ + "agents": { + "agent": "main.py:agent" + } +} +``` + +Every key under `agents` becomes an entry point, so a project can expose several agents from one package: + +```json +{ + "agents": { + "researcher": "research.py:agent", + "reviewer": "review.py:agent" + } +} +``` + +`uipath init` reads this file and produces the `uipath.json`, `entry-points.json` and `bindings.json` that packaging and deployment need. For more details on the configuration format, see the [UiPath configuration specifications](https://github.com/UiPath/uipath-python/blob/main/specs/README.md). + +### Environment variables + +`uipath auth` writes the UiPath credentials into a `.env` file in your project root: + +``` +UIPATH_URL=https://cloud.uipath.com/ACCOUNT_NAME/TENANT_NAME +UIPATH_ACCESS_TOKEN=YOUR_TOKEN_HERE +UIPATH_TENANT_ID=YOUR_TENANT_ID +UIPATH_ORGANIZATION_ID=YOUR_ORGANIZATION_ID +``` + +An agent that declares `uipath_llm` uses those credentials to resolve its model and to route every call. An agent without `uipath_llm` needs `ANTHROPIC_API_KEY` instead for model access, and still needs the UiPath credentials to publish. + +### Conversational agents + +Set `runtimeOptions.isConversational` in `uipath.json` to run the agent as a multi-turn chat agent: + +```json +{ + "runtimeOptions": { + "isConversational": true + } +} +``` + +The conversational runtime handles one exchange per invocation. It streams the assistant response as UiPath conversation message events, then suspends with an API resume trigger to wait for the next user message. The Claude session id is persisted so the SDK re-attaches to the same session, and a stable per-conversation workspace directory keeps files written by the agent across exchanges. Input and output then use the UiPath conversation message format rather than the agent's own input and output schemas. + +## Command line interface + +The `uipath` CLI commands all work against a Claude SDK project: + +| Command | Purpose | +|---------|---------| +| `uipath auth` | Authenticate and populate `.env` | +| `uipath new NAME` | Scaffold a new Claude SDK agent project in the current directory | +| `uipath init` | Generate `uipath.json`, `entry-points.json`, `bindings.json` and the mermaid graph from `claude.json` | +| `uipath run AGENT [INPUT]` | Execute an agent locally with JSON input, inline or with `-f/--file` | +| `uipath pack` | Package the project into a `.nupkg` | +| `uipath publish` | Publish the package to Orchestrator | + +`uipath pack` requires a `description` field and author information in `pyproject.toml`. + +## Project structure + +To use the CLI for packaging and publishing, your project should include: + +- A `pyproject.toml` file with project metadata +- A `claude.json` file with your agent definitions (for example `"agents": {"agent": "main.py:agent"}`) +- A `uipath.json` file (generated by `uipath init`) +- An `entry-points.json` file (generated by `uipath init`) +- A `bindings.json` file (generated by `uipath init`) to configure resource overrides +- Any Python files needed for your agent + +## Not yet supported + +These are not implemented in this package today. Do not rely on them yet. + +- Tool approval. A tool call is never routed to a human for permission. What is supported is the developer-driven suspension described under [Human in the loop](#human-in-the-loop) +- Evaluations +- Interactive debugging + +## Development + +### Developer tools + +Check out [uipath-dev](https://github.com/uipath/uipath-dev-python), an interactive application for building, testing, and debugging UiPath Python runtimes, agents, and automation scripts. + +### Setting up a development environment + +Please read our [contribution guidelines](https://github.com/UiPath/uipath-integrations-python/blob/main/packages/uipath-claude-sdk/CONTRIBUTING.md) before submitting a pull request. + +### Special thanks + +A huge thank-you to the open-source community and the maintainers of the libraries that make this project possible: + +- [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python) for the agent harness this package builds on. +- [Pydantic](https://github.com/pydantic/pydantic) for reliable, typed configuration and validation. diff --git a/packages/uipath-claude-sdk/pyproject.toml b/packages/uipath-claude-sdk/pyproject.toml new file mode 100644 index 00000000..8078f8fe --- /dev/null +++ b/packages/uipath-claude-sdk/pyproject.toml @@ -0,0 +1,144 @@ +[project] +name = "uipath-claude-sdk" +version = "0.1.0" +description = "Python SDK that enables developers to build and deploy Claude Agent SDK agents to the UiPath Cloud Platform" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "claude-agent-sdk>=0.2.132, <0.3.0", + "openinference-instrumentation-claude-agent-sdk>=0.1.9, <0.2.0", + "aiohttp>=3.9.0", + "httpx>=0.27.0", + "botocore>=1.34.0", + "aiosqlite>=0.19.0", + "uipath>=2.14.0, <2.15.0", + "uipath-runtime>=0.13.0, <0.14.0", + "uipath-llm-client>=1.17.1, <1.18.0", +] +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +maintainers = [ + { name = "Radu Mocanu", email = "radu.mocanu@uipath.com" } +] + +[project.entry-points."uipath.middlewares"] +register = "uipath_claude_sdk.middlewares:register_middleware" + +[project.entry-points."uipath.runtime.factories"] +claude = "uipath_claude_sdk.runtime:register_runtime_factory" + +[project.urls] +Homepage = "https://uipath.com" +Repository = "https://github.com/UiPath/uipath-integrations-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "mypy>=1.14.1", + "ruff>=0.9.4", + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.1", + "pre-commit>=4.1.0", + "pytest-asyncio>=1.0.0", +] + +[tool.ruff] +line-length = 88 +indent-width = 4 +exclude = ["samples/**", "testcases/**", "template/**"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I"] + +[tool.ruff.lint.per-file-ignores] +"*" = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +plugins = [ + "pydantic.mypy" +] +exclude = [ + "samples/.*", + "testcases/.*", + "template/.*" +] +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = false +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = "claude_agent_sdk" +ignore_missing_imports = true +ignore_errors = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-ra -q --cov=src/uipath_claude_sdk --cov-report=term-missing" +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" +markers = [ + "defer_contract: spawns the real bundled Claude CLI against a local stub, slower than the rest of the suite (deselect with -m 'not defer_contract')", + "live: calls a live model through the configured UiPath LLM Gateway (deselect with -m 'not live')", +] + +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.exclude-newer-package] +uipath = false +uipath-core = false +uipath-platform = false +uipath-runtime = false +claude-agent-sdk = false +openinference-instrumentation-claude-agent-sdk = false + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + +[tool.coverage.run] +source = ["src/uipath_claude_sdk"] +relative_files = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", + "*/conftest.py", +] + +[tool.coverage.report] +show_missing = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] + +[[tool.mypy.overrides]] +module = "botocore.*" +ignore_missing_imports = true diff --git a/packages/uipath-claude-sdk/samples/README.md b/packages/uipath-claude-sdk/samples/README.md new file mode 100644 index 00000000..d022091f --- /dev/null +++ b/packages/uipath-claude-sdk/samples/README.md @@ -0,0 +1,199 @@ +# Samples + +Each sample is a standalone project demonstrating one capability. They resolve +`uipath-claude-sdk` from this checkout through `[tool.uv.sources]`, so what you run is the code in +this worktree, not a published release. + +## Two ways to run + +**With a UiPath tenant.** The sample as written declares `uipath_llm=UiPathModel("claude-sonnet-4-5")`, +so model calls route through the UiPath LLM Gateway and the model id is resolved against your tenant's +catalogue. + +```bash +cd samples/quickstart-agent +uv sync +uv run uipath auth +uv run uipath init +uv run uipath run agent --file input.json +``` + +**Without a tenant, using your own Anthropic key.** Delete the `uipath_llm=` line from `main.py` and +export a key. Nothing UiPath-specific is injected in that case, so the Claude Agent SDK reaches +Anthropic directly. The samples set no `model` on `ClaudeAgentOptions`, so the CLI picks its own +default. Add `model="claude-sonnet-4-5"` to the options if you want to choose. + +```bash +cd samples/quickstart-agent +uv sync +export ANTHROPIC_API_KEY=sk-ant-... +uv run uipath init +uv run uipath run agent --file input.json +``` + +The second path is the quickest way to see an agent actually run. Suspension still works on it, because +API and timer triggers are created locally without calling the platform. + +## Nothing is injected + +An agent is what its code says. The runtime adds no tools, no MCP server and no system prompt of its +own, so an agent written for the Claude Agent SDK runs on UiPath unchanged. + +Human in the loop is something you add. Write an ordinary SDK tool, call `interrupt()` inside it, and +register the tool through `uipath_tool_server`: + +```python +from uipath.platform.common import CreateTask +from uipath_claude_sdk import interrupt, uipath_tool_server + +@tool("request_approval", "Ask a human to approve it.", {"question": str}) +async def request_approval(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + title="Action Required", + data={"AgentOutput": args["question"]}, + app_name="generic_escalation_app", + app_folder_path="Shared", + ) + ) + approved = action_data.get("Answer") is True + return {"content": [{"type": "text", "text": f"Approved: {approved}"}]} + + +server = uipath_tool_server("approvals", tools=[request_approval]) +options = ClaudeAgentOptions(mcp_servers={"approvals": server}) +``` + +The value passed to `interrupt()` decides the resume trigger: `CreateTask` means an Action Center +task, `InvokeProcess` or `WaitJob` mean a job, `WaitIntegrationEvent` means an Integration Services +event, `WaitUntil` means a timer, and anything else, including a plain string, becomes an API trigger +answered by the resume input. A list becomes sibling triggers resolved by whichever fires first. + +An agent that never calls `interrupt()` gets no UiPath surface at all. + +## What each sample needs + +| Sample | Shows | Anthropic key only | Needs a tenant | +| --- | --- | --- | --- | +| `quickstart-agent` | Typed input and output, one in-process tool | Yes | | +| `autonomous-agent` | Built-in file tools, multi-step work | Yes | | +| `chat-agent` | `isConversational`, a multi-turn conversation across invocations | Yes | | +| `simple-local-mcp-agent` | A stdio MCP server as a subprocess | Yes | | +| `simple-remote-mcp-agent` | A remote MCP server over HTTP | | An MCP server endpoint | +| `simple-hitl-agent` | `interrupt(question)`, an API trigger, resume in a new process | Yes | | +| `action-center-hitl-agent` | `interrupt(CreateTask(...))` | | Action Center app | +| `multi-agent` | `interrupt(InvokeProcess(...))`, delegating to another process | | A published process | +| `integration-event-agent` | `interrupt(WaitIntegrationEvent(...))` | | An Integration Services connection | + +The last three suspend on triggers the platform has to create, so they fail at suspend time without +credentials. `simple-hitl-agent` does not, because its plain-string interrupt becomes an API trigger +the runtime creates locally. + +## Seeing a suspension + +`simple-hitl-agent` is the one to try, and it needs nothing but an Anthropic key: + +```bash +cd samples/simple-hitl-agent +uv sync +export ANTHROPIC_API_KEY=sk-ant-... +uv run uipath init +uv run uipath run agent --file input.json # suspends, prints the question +uv run uipath run agent '{"answer": "yes"}' --resume +``` + +The first command ends with the job suspended: the model called the sample's own +`mcp__approvals__ask_approver`, a `PreToolUse` hook ran its body, `interrupt()` raised out of it, the +CLI parked the call, and the runtime stored the pending record in `__uipath/state.db`. The second +command starts a **new process**, re-attaches to the Claude session, runs the same body again with +`interrupt()` returning your answer, and hands the body's return value back as that parked call's own +tool result, so the agent continues mid-turn rather than being told about it afterwards. + +Because the body reruns, work performed **before** the `interrupt()` call happens twice. That is the +same replay LangGraph does in a node. Keep side effects after the call, or make them idempotent. + +Look at `__uipath/state.db` and `__uipath/claude_home/projects/*/*.jsonl` between the two commands to +see what actually persists. + +## Seeing a conversation + +`chat-agent` is the same suspension mechanism used for a different purpose. `isConversational` in its +`uipath.json` selects a runtime that runs one exchange per invocation and then suspends on an API +trigger waiting for the next user message, so **every turn after the first is a `--resume`**: + +```bash +cd samples/chat-agent +uv sync +export ANTHROPIC_API_KEY=sk-ant-... +uv run uipath init +uv run uipath run agent --file input.json +uv run uipath run agent --file next_message.json --resume +``` + +The second message asks the agent to recall something only the first turn established. Getting it right +means the message reached the model and the Claude session was resumed, which are two separate things +that both have to work. + +Nothing in that agent's code opts into any of this, and it registers no `uipath_tool_server`, so no tool +call is ever parked. The waiting between turns and the waiting for a human are the same primitive with +different triggers, and an agent can use both. + +## Poking at it + +The agents are plain Claude Agent SDK objects, so everything you already know applies. Things worth +trying: + +- Change `system_prompt` and watch the tool-calling behaviour change. +- Add a tool with `@tool` and `create_sdk_mcp_server`, then register it in `mcp_servers`. Use + `uipath_tool_server` instead when the tool needs to suspend. +- Swap `input_schema` and `output_schema` for your own pydantic models, re-run `uipath init`, and look + at the regenerated `entry-points.json` and `agent.mermaid`. +- Drop `output_schema` entirely and see the run fall back to untyped output. +- In `simple-hitl-agent`, ask the model to call the suspending tool alongside another tool in the same + turn. A suspending call has to be the last one of its turn, so this is the one thing to be careful with. +- Change what `simple-hitl-agent` passes to `interrupt()`, from the plain question string to a + `WaitUntil(resume_time=...)`, and watch the trigger kind change with no other edit. + +## Packing and deploying before the package is published + +`uipath-claude-sdk` is not on PyPI yet, so a packed sample has no way to install it. The editable +`[tool.uv.sources]` entry that makes `uipath run` work locally points **outside** the project +directory, and `uipath pack` only walks the project directory, so the dependency is simply absent from +the `.nupkg` and the run fails on the executor at install time. + +Vendor the library into the package instead: + +```bash +./scripts/pack_sample.sh samples/quickstart-agent +``` + +That builds a wheel into the sample's gitignored `wheels/`, repoints `[tool.uv.sources]` at it, adds +`.whl` to `packOptions.fileExtensionsIncluded` so the packer picks binaries up, locks, packs, and puts +every file it touched back the way it was. The resulting `.nupkg` carries +`content/wheels/uipath_claude_sdk--py3-none-any.whl`, and the packed `uv.lock` refers to it by +a **relative** path, so it resolves wherever the executor extracts the package. + +Doing it by hand is four edits, if you would rather see them: + +```toml +# pyproject.toml +[tool.uv.sources] +uipath-claude-sdk = { path = "wheels/uipath_claude_sdk-0.1.0-py3-none-any.whl" } +``` + +```json +// uipath.json +{ "packOptions": { "fileExtensionsIncluded": [".whl"] } } +``` + +then `uv build --wheel --project ../.. --out-dir wheels`, `uv lock` and `uipath pack`. + +The wheel is a snapshot. Re-run the script after every change to `src/uipath_claude_sdk`, or you will +deploy stale code. Once the package is published, delete all of this and depend on a version. + +## Notes + +`uipath init` writes `entry-points.json`, `bindings.json` and `agent.mermaid`, and `uipath pack` builds +a `.nupkg` under `.uipath/`. Neither needs credentials. + +Tracing, evaluations and interactive debugging are not wired up yet. diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/.env.example b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/README.md b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/README.md new file mode 100644 index 00000000..f0735ea7 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/README.md @@ -0,0 +1,128 @@ +# Action Center HITL Agent + +A Claude Agent SDK agent that raises an Action Center action for a human and suspends the job until that action is completed. + +## Overview + +Nothing UiPath-specific is injected. The developer writes an ordinary SDK tool and calls `interrupt()` inside it with a `CreateTask` imported from `uipath.platform.common`: + +```python +from uipath.platform.common import CreateTask +from uipath_claude_sdk import interrupt, uipath_tool_server + +ESCALATION_APP = "generic_escalation_app" +ESCALATION_FOLDER = "Shared" + + +@tool("request_approval", "...", {"report_id": str, "question": str}) +async def request_approval(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + title=f"Expense approval {args['report_id']}", + data={"AgentName": "Expense agent", "AgentOutput": args["question"]}, + app_name=ESCALATION_APP, + app_folder_path=ESCALATION_FOLDER, + ) + ) + ... + + +approvals_server = uipath_tool_server("approvals", tools=[request_approval]) +``` + +Passing a `CreateTask` is what makes this a TASK resume trigger. The platform decides the trigger kind from the value's type, so switching this agent to a job, a timer or an Integration Services event is a matter of passing a different model, with no other change. + +The app name and the folder are deployment configuration, so they are module constants. The model never sees them, and the system prompt describes the agent's job rather than the escalation plumbing. + +`book_expense` needs no suspension, so it stays in a normal `create_sdk_mcp_server`. Only tools that can call `interrupt()` belong in a `uipath_tool_server`. + +Nothing is held open in memory: the process ends, the action sits in Action Center for as long as it takes, and the run continues in a fresh process once a human has worked it. The completed action's data arrives at the agent as the result of the very call that was parked, so the model keeps its full context instead of being re-prompted. + +## Prerequisites + +- An Action Center app named `generic_escalation_app` in the `Shared` folder, with the `AgentName` and `AgentOutput` fields and an `Answer` output field. Change `ESCALATION_APP`, `ESCALATION_FOLDER` and the `data` keys in `main.py` to match your own app. +- Nothing else. The action is created by the platform from the suspend value, so the agent needs no Orchestrator client of its own. + +## How it works + +1. The input is validated against `ExpenseReport` and the `prompt` template renders the user message +2. The system prompt tells the agent to escalate every report, so it calls `mcp__approvals__request_approval` with the report id and a question +3. A `PreToolUse` hook runs the tool body. `interrupt()` raises out of it carrying the `CreateTask`, the hook records the pending suspension against the call's `tool_use_id` and defers the call, and the run ends as SUSPENDED with that model as its output +4. UiPath turns it into a TASK resume trigger and creates the action in Action Center +5. A human opens the action, answers, and completes it +6. On resume the runtime rebuilds the hook, re-attaches to the same Claude session and runs the body again. This time `interrupt()` returns the completed action's data, the body maps it to the decision the model reads, and that return value is delivered as the parked call's tool result +7. The agent calls `book_expense` or refuses, and the result is validated against `ExpenseDecision` + +The body runs twice across a suspension, exactly as a LangGraph node does. Anything before the `interrupt()` call happens on both passes, so keep side effects after it or make them idempotent. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + request_approval(request_approval) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |suspend|request_approval + request_approval --> |completed action data|agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| --------------------------------- | ---------------------------------------------------------------------- | +| `mcp__approvals__request_approval` | Raises an Action Center action and suspends until a human completes it | +| `mcp__expenses__book_expense` | Books the expense, only after the human approved it | + +## Input / Output + +```json +// Input +{ + "report_id": "EXP-2291", + "employee": "Dana Petrescu", + "amount": 1840.5, + "category": "Travel", + "justification": "Flights and hotel for the customer workshop in Munich." +} + +// Output +{ + "approved": true, + "booked_amount": 1840.5, + "summary": "..." +} +``` + +## Running the agent + +**Initial run (creates the action and suspends):** +```bash +uipath run agent --file input.json +``` + +The run ends as SUSPENDED. Open Action Center, work the action, and complete it. + +**Resume once the action is completed:** +```bash +uipath run agent --resume +``` + +The resume needs no payload. A TASK trigger is pollable, so the runtime reads the completed action itself and hands its data to the re-run body. + +## Key features + +- **Opt-in, not injected**: the agent has exactly the tools it declares, and the escalation lives in the developer's own tool +- **Durable suspension**: the job can end between the escalation and the human's decision +- **No new prompt on resume**: the action data comes back as the parked tool call's result +- **The platform model picks the trigger**: `CreateTask` means TASK, and any other model from `uipath.platform.common` works the same way with no change here +- **Structured output**: input and output are Pydantic models, validated on both ends + +## Assigning the action + +`CreateTask` also accepts `assignee` (an email address) or a `recipient` object for group, workload or round-robin routing, plus `priority` and `labels`. Set them in the tool body, from a tool argument when the agent should choose the routing. diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/claude.json b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/input.json b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/input.json new file mode 100644 index 00000000..e0f96182 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/input.json @@ -0,0 +1,7 @@ +{ + "report_id": "EXP-2291", + "employee": "Dana Petrescu", + "amount": 1840.5, + "category": "Travel", + "justification": "Flights and hotel for the customer workshop in Munich." +} diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/main.py b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/main.py new file mode 100644 index 00000000..a3e6e6a2 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/main.py @@ -0,0 +1,103 @@ +"""Expense agent that escalates to Action Center and waits for a human decision.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field +from uipath.platform.common import CreateTask + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +ESCALATION_APP = "generic_escalation_app" +ESCALATION_FOLDER = "Shared" + +BOOKED: dict[str, float] = {} + + +@tool( + "request_approval", + "Ask a human to approve or refuse an expense, and wait for their decision.", + {"report_id": str, "question": str}, +) +async def request_approval(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + title=f"Expense approval {args['report_id']}", + data={"AgentName": "Expense agent", "AgentOutput": args["question"]}, + app_name=ESCALATION_APP, + app_folder_path=ESCALATION_FOLDER, + ) + ) + decision = { + "answer": action_data.get("Answer"), + "comment": action_data.get("Comment", ""), + } + return {"content": [{"type": "text", "text": json.dumps(decision, default=str)}]} + + +@tool( + "book_expense", + "Book an approved expense against the cost centre. Never call this before a " + "human has approved the expense.", + {"report_id": str, "amount": float}, +) +async def book_expense(args: dict[str, Any]) -> dict[str, Any]: + BOOKED[args["report_id"]] = args["amount"] + return { + "content": [ + { + "type": "text", + "text": f"Booked {args['amount']} for report {args['report_id']}.", + } + ] + } + + +approvals_server = uipath_tool_server("approvals", tools=[request_approval]) +expenses_server = create_sdk_mcp_server(name="expenses", tools=[book_expense]) + + +class ExpenseReport(BaseModel): + report_id: str = Field(description="Identifier of the expense report.") + employee: str = Field(description="Person who filed the report.") + amount: float = Field(description="Total amount claimed.") + category: str = Field(description="Expense category, e.g. 'Travel'.") + justification: str = Field(description="Why the employee claims the expense.") + + +class ExpenseDecision(BaseModel): + approved: bool = Field(description="Whether the human approved the expense.") + booked_amount: float = Field(description="Amount booked, 0 when refused.") + summary: str = Field(description="One sentence describing what happened.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You review employee expense reports. You never decide yourself. " + "For every report call request_approval exactly once, on its own and " + "never alongside another tool call, passing the report id and a " + "question naming the employee, the amount, the category and the " + "justification. It comes back with the human's decision. Call " + "book_expense with the full amount when the decision approves the " + "report, and leave it unbooked otherwise." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"approvals": approvals_server, "expenses": expenses_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ExpenseReport, + output_schema=ExpenseDecision, + prompt=( + "Expense report {report_id} filed by {employee}: {amount} for " + "{category}. Justification: {justification}" + ), +) diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/pyproject.toml new file mode 100644 index 00000000..38bfc5dc --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "claude-action-center-hitl-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Action Center HITL Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/action-center-hitl-agent/uipath.json b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/action-center-hitl-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/samples/autonomous-agent/README.md b/packages/uipath-claude-sdk/samples/autonomous-agent/README.md new file mode 100644 index 00000000..07bd45be --- /dev/null +++ b/packages/uipath-claude-sdk/samples/autonomous-agent/README.md @@ -0,0 +1,17 @@ +# Autonomous Agent + +A multi-step agent that works autonomously in an isolated workspace using the Claude Agent SDK built-in file tools (Read, Write, Edit, Glob, Grep), then returns a structured result via `output_format`. + +## Run + +``` +uipath auth +uipath init +uipath run agent '{"input": "Compare the pros and cons of SQLite vs Postgres for a small internal tool and give a recommendation."}' +``` + +## Debug + +``` +uipath dev +``` diff --git a/packages/uipath-claude-sdk/samples/autonomous-agent/claude.json b/packages/uipath-claude-sdk/samples/autonomous-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/autonomous-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/autonomous-agent/main.py b/packages/uipath-claude-sdk/samples/autonomous-agent/main.py new file mode 100644 index 00000000..44e22490 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/autonomous-agent/main.py @@ -0,0 +1,29 @@ +"""Autonomous multi-step Claude Agent SDK agent.""" + +from claude_agent_sdk import ClaudeAgentOptions + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are an autonomous analyst. Work step by step: draft your notes " + "in files, refine them, then produce the final answer." + ), + max_turns=25, + permission_mode="bypassPermissions", + tools=["Read", "Write", "Edit", "Glob", "Grep"], + output_format={ + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"}, + "steps_taken": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["answer", "steps_taken"], + }, + }, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), +) diff --git a/packages/uipath-claude-sdk/samples/autonomous-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/autonomous-agent/pyproject.toml new file mode 100644 index 00000000..7a465a79 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/autonomous-agent/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "autonomous-agent" +version = "0.0.1" +description = "Autonomous Claude Agent SDK agent example" +authors = [{ name = "John Doe" }] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "uipath", + "uipath-claude-sdk", + "claude-agent-sdk>=0.1.71", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/autonomous-agent/uipath.json b/packages/uipath-claude-sdk/samples/autonomous-agent/uipath.json new file mode 100644 index 00000000..adf2a554 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/autonomous-agent/uipath.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {} +} diff --git a/packages/uipath-claude-sdk/samples/chat-agent/.env.example b/packages/uipath-claude-sdk/samples/chat-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/chat-agent/README.md b/packages/uipath-claude-sdk/samples/chat-agent/README.md new file mode 100644 index 00000000..89201776 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/README.md @@ -0,0 +1,179 @@ +# Chat Agent + +A multi-turn Claude Agent SDK agent that answers questions about meeting rooms. One exchange per +invocation, with the conversation carried across invocations by the runtime rather than by anything +in the agent. + +## Overview + +The only thing that makes this a chat agent is one line in `uipath.json`: + +```json +{ + "runtimeOptions": { + "isConversational": true + } +} +``` + +`uipath init` writes it, the factory reads it at startup, and the conversational runtime is selected +instead of the standard one. `main.py` is an ordinary `UiPathClaudeAgent` with one in-process tool and +a system prompt. Nothing in it knows it is conversational: no `input_schema`, no `output_schema` and no +`prompt` template, because in this mode input and output are the UiPath conversation message format +rather than the agent's own. + +The agent is honest about its limits. It reads a fixed room list and suggests the smallest room that +fits, and it says plainly that it cannot book, hold or cancel anything, or tell you who is using a room +when. There is nothing behind it that could. + +## How an exchange works + +1. The runtime pulls the newest user message out of the conversation input, whether it arrives as the + first invocation's `messages` or wrapped in the `{interrupt_id: payload}` map a resume carries +2. It re-attaches to the stored Claude session id, so the CLI continues the same conversation rather + than starting a new one +3. The assistant's text is streamed as UiPath conversation message events (message start, + content-part chunks, message end); tool calls and thinking stay state events +4. The Claude session id and the CLI's session transcript are stored, then the exchange ends + **suspended on nothing**. The suspension keeps the job and its state alive for the next message; + carrying no interrupt is what tells a chat host the turn is over + +That last part matters. A suspension that carries an interrupt is a turn still owed an answer, and a +chat host keeps the exchange open until it gets one. A finished turn has nothing outstanding, so it +suspends empty, and the host closes the exchange and unlocks the composer. + +Every exchange after the first therefore arrives as a resume. That is the runtime's own mechanism for +waiting, and it is unrelated to `interrupt()`: this agent registers no `uipath_tool_server`, so it never +parks a tool call. Add one and both kinds of suspension coexist, an interrupt inside an exchange and the +plain wait between exchanges. + +The workspace directory is keyed on the conversation, not on the invocation, so a file the agent writes +in one turn is still there in the next. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| ------------ | ------------------------------------------------------------------ | +| `find_rooms` | Lists the rooms seating at least a given headcount (fixed sample data) | + +## Input / Output + +Input is a UiPath conversation message. Only the newest user message is sent to the model; everything +earlier is already in the Claude session. + +```json +// input.json +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { "inline": "I need a room for 6 people ..." } + } + ] + } + ] +} +``` + +The assistant's reply comes back as conversation message events on the stream. The exchange's result is +always `SUSPENDED`, and on a finished turn its output is empty: + +```json +{ + "output": {}, + "status": "suspended" +} +``` + +## Running the agent + +**With a UiPath tenant.** As written, `uipath_llm=UiPathModel("claude-sonnet-4-5")` routes every model +call through the UiPath LLM Gateway. + +```bash +uv sync +uv run uipath auth +uv run uipath init +uv run uipath run agent --file input.json # first message +uv run uipath run agent --file next_message.json --resume # second message +``` + +**Without a tenant.** Delete the `uipath_llm=` line from `main.py` and export an Anthropic key. Nothing +UiPath-specific is injected then, and the two commands above are unchanged. + +```bash +uv sync +export ANTHROPIC_API_KEY=sk-ant-... +uv run uipath init +uv run uipath run agent --file input.json +uv run uipath run agent --file next_message.json --resume +``` + +`next_message.json` deliberately asks the agent to recall something only the first turn established: + +``` +Remind me which room you suggested and what the meeting was called. +``` + +A correct answer names Beehive and the Kingfisher review. Both come from turn one, so an answer that +has them is proof the second message reached the model *and* that the Claude session was resumed. A +reply that asks what you are talking about means one of the two did not happen. + +The terminal will not show you that answer. `uipath run` renders state events, which is why the tool +calls and the model's thinking appear, and the assistant's text is a conversation message event, which +a chat host consumes and the console does not print. Read it in the session transcript instead: + +```bash +python3 -c " +import glob, json, os +path = max(glob.glob('__uipath/claude_home/projects/*/*.jsonl'), key=os.path.getsize) +for line in open(path): + message = json.loads(line).get('message') or {} + content = message.get('content') + if isinstance(content, str): + print('you >', content) + for block in content if isinstance(content, list) else []: + if block.get('type') == 'text': + print('agent >', block['text']) +" +``` + +Keep going with a third message by editing `next_message.json` and repeating the `--resume` command. +The conversation only ends when you stop resuming it. + +## What persists between turns + +Look inside `__uipath/` between two invocations: + +- `state.db` holds the Claude session id and the CLI's session transcript, plus any interrupt an + exchange is parked on +- `claude_home/projects/*/*.jsonl` is the CLI's own transcript of the conversation + +Only the state database is guaranteed to survive on the platform, which is why the transcript is copied +into it and restored on the far side. + +## Key features + +- **One config flag**: `runtimeOptions.isConversational` is the whole opt-in. The agent code is unchanged +- **Real session continuity**: the SDK re-attaches to the same Claude session, so history is the CLI's, + not a message list this runtime replays +- **Durable between turns**: the process can end between two messages +- **Stable workspace**: files the agent writes survive from one exchange to the next +- **Streaming text**: assistant text arrives as UiPath conversation message events, ready for a chat host diff --git a/packages/uipath-claude-sdk/samples/chat-agent/claude.json b/packages/uipath-claude-sdk/samples/chat-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/chat-agent/input.json b/packages/uipath-claude-sdk/samples/chat-agent/input.json new file mode 100644 index 00000000..7a28e685 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/input.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "I need a room for 6 people on Tuesday at 10:00 for the Kingfisher review. What fits?" + } + } + ] + } + ] +} diff --git a/packages/uipath-claude-sdk/samples/chat-agent/main.py b/packages/uipath-claude-sdk/samples/chat-agent/main.py new file mode 100644 index 00000000..56ef658b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/main.py @@ -0,0 +1,57 @@ +"""Meeting-room assistant that answers one message per invocation.""" + +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +ROOMS = [ + {"name": "Aviary", "seats": 4, "floor": 1}, + {"name": "Beehive", "seats": 8, "floor": 2}, + {"name": "Cedar Hall", "seats": 20, "floor": 3}, +] + + +@tool( + "find_rooms", + "List the meeting rooms that seat at least the given number of people.", + {"people": int}, +) +async def find_rooms(args: dict[str, Any]) -> dict[str, Any]: + people = int(args["people"]) + matches = [room for room in ROOMS if int(room["seats"]) >= people] + if not matches: + return { + "content": [ + {"type": "text", "text": f"No room seats {people} people."}, + ] + } + listing = "\n".join( + f"{room['name']}: {room['seats']} seats, floor {room['floor']}" + for room in matches + ) + return {"content": [{"type": "text", "text": listing}]} + + +rooms_server = create_sdk_mcp_server(name="rooms", tools=[find_rooms]) + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You help colleagues pick a meeting room. Call find_rooms to see what " + "seats a given headcount, and suggest the smallest room that fits. " + "You can only read the room list: you cannot book, hold or cancel " + "anything, and you know nothing about who is using a room when. Say " + "so plainly when you are asked for something you cannot do. Answer in " + "one or two sentences, and use what the person already told you " + "earlier in the conversation rather than asking for it again." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"rooms": rooms_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), +) diff --git a/packages/uipath-claude-sdk/samples/chat-agent/next_message.json b/packages/uipath-claude-sdk/samples/chat-agent/next_message.json new file mode 100644 index 00000000..ea0d1413 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/next_message.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "Remind me which room you suggested and what the meeting was called." + } + } + ] + } + ] +} diff --git a/packages/uipath-claude-sdk/samples/chat-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/chat-agent/pyproject.toml new file mode 100644 index 00000000..0e898ba3 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "claude-chat-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Conversational Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath-dev>=0.0.84", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/samples/chat-agent/uipath.json b/packages/uipath-claude-sdk/samples/chat-agent/uipath.json new file mode 100644 index 00000000..312da547 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/chat-agent/uipath.json @@ -0,0 +1,6 @@ +{ + "runtimeOptions": { + "isConversational": true + }, + "id": "5e0f8e89-8dd3-485d-beb7-3ea39b7f06e3" +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/.env.example b/packages/uipath-claude-sdk/samples/integration-event-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/README.md b/packages/uipath-claude-sdk/samples/integration-event-agent/README.md new file mode 100644 index 00000000..1c7e320c --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/README.md @@ -0,0 +1,126 @@ +# Integration Event Agent + +A Claude Agent SDK agent that escalates an incident and suspends until a remote event, such as a Slack message or a Teams reply, is delivered through Integration Services. + +## Overview + +Nothing UiPath-specific is injected. The agent declares one tool of its own, and that tool calls `interrupt()` with a `WaitIntegrationEvent` imported from `uipath.platform.common`: + +```python +from uipath.platform.common import WaitIntegrationEvent +from uipath_claude_sdk import interrupt, uipath_tool_server + +CONNECTOR = "uipath-slack" +CONNECTION_NAME = "Slack-OnCall" +OPERATION = "OnMessage" +OBJECT_NAME = "Message" +FILTER_EXPRESSION = "channel == 'oncall'" + + +@tool("await_oncall_reply", "...", {}) +async def await_oncall_reply(args: dict[str, Any]) -> dict[str, Any]: + event = await interrupt( + WaitIntegrationEvent( + connector=CONNECTOR, + connection_name=CONNECTION_NAME, + operation=OPERATION, + object_name=OBJECT_NAME, + filter_expression=FILTER_EXPRESSION, + ) + ) + ... + + +oncall_server = uipath_tool_server("oncall", tools=[await_oncall_reply]) +``` + +Passing a `WaitIntegrationEvent` is what makes this an INBOX resume trigger. The platform decides the trigger kind from the value's type. + +The connection details are module constants rather than tool arguments, because they are deployment configuration: the connector key, the operation and the object name come from the connector's own event catalogue and differ per connector and per tenant. The model is told to wait for a reply, not how the tenant is wired. + +## Prerequisites + +**This sample is not runnable on a bare tenant.** It needs a configured Integration Services connection, and the constants at the top of `main.py` are placeholders. Edit them to match your tenant: + +- `CONNECTOR` is the connector key, for example `uipath-slack` +- `CONNECTION_NAME` is the name of a connection you have already created in Integration Service, resolved to its connection id when the trigger is created. Set `connection_folder_path` too when the connection lives outside the run's folder +- `OPERATION` and `OBJECT_NAME` name an event the connector actually publishes. Take them from the connector's event triggers, do not guess +- `FILTER_EXPRESSION` narrows which events wake the run, and is optional + +Nothing sends the escalation message itself. The agent only waits. Post the message from a connector activity, a flow, or a person, and the sample handles the reply. + +## How it works + +1. The input is validated against `EscalationRequest` and the `prompt` template renders the user message +2. The agent calls `mcp__oncall__await_oncall_reply` +3. A `PreToolUse` hook runs the tool body. `interrupt()` raises out of it carrying the `WaitIntegrationEvent`, the hook records the pending suspension against the call's `tool_use_id` and defers the call, and the run ends as SUSPENDED with that model as its output +4. UiPath resolves the connection name to a connection id, generates an inbox id and creates an INBOX resume trigger +5. When the connector delivers a matching event, the job resumes. The runtime rebuilds the hook, re-attaches to the same Claude session and runs the body again. This time `interrupt()` returns the event payload, and the body's return value is delivered as the parked call's tool result +6. The agent reads the reply and the result is validated against `EscalationOutcome` + +An INBOX trigger is not pollable. Unlike a task or a job, there is nothing to check, so the run stays suspended until the event is pushed to it. + +The body runs twice across a suspension, exactly as a LangGraph node does. Anything before the `interrupt()` call happens on both passes, so keep side effects after it or make them idempotent. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + await_oncall_reply(await_oncall_reply) + __end__(__end__) + __start__ --> |input|agent + agent --> |suspend|await_oncall_reply + await_oncall_reply --> |event payload|agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| --------------------------------- | ------------------------------------------------------------------ | +| `mcp__oncall__await_oncall_reply` | Suspends until Integration Services delivers a matching event | + +## Input / Output + +```json +// Input +{ + "incident": "Checkout latency above 2s for the last 15 minutes in eu-west-1." +} + +// Output +{ + "acknowledged": true, + "responder": "...", + "next_step": "...", + "summary": "..." +} +``` + +## Running the agent + +**Initial run (subscribes to the event and suspends):** +```bash +uipath run agent --file input.json +``` + +**Resume when the event arrives:** + +Deployed, the resume is automatic. Integration Services delivers the event to the trigger's inbox and the job continues on its own. + +Locally there is no delivery, so feed a payload by hand to see the rest of the run: +```bash +uipath run agent '{"user": "alex", "text": "I am on it, rolling back the last deploy."}' --resume +``` + +That payload is what `interrupt()` returns inside the tool body, exactly as a delivered event would be, which makes it a usable way to test the agent's handling of the reply without a live connector. + +## Key features + +- **Durable suspension on a remote event**: the job holds nothing open while it waits for the outside world +- **No new prompt on resume**: the event payload comes back as the parked tool call's result +- **Connection resolved by name**: the platform maps `connection_name` to the connection id, so the agent carries no ids +- **Opt-in, not injected**: the wait lives in the developer's own tool, and the platform model passed to `interrupt()` picks the trigger kind +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/claude.json b/packages/uipath-claude-sdk/samples/integration-event-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/input.json b/packages/uipath-claude-sdk/samples/integration-event-agent/input.json new file mode 100644 index 00000000..74ec59c2 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/input.json @@ -0,0 +1,3 @@ +{ + "incident": "Checkout latency above 2s for the last 15 minutes in eu-west-1." +} diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/main.py b/packages/uipath-claude-sdk/samples/integration-event-agent/main.py new file mode 100644 index 00000000..aae19247 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/main.py @@ -0,0 +1,80 @@ +"""On-call agent that suspends until Integration Services delivers a reply.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, tool +from pydantic import BaseModel, Field +from uipath.platform.common import WaitIntegrationEvent + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +CONNECTOR = "uipath-slack" +CONNECTION_NAME = "Slack-OnCall" +OPERATION = "OnMessage" +OBJECT_NAME = "Message" +FILTER_EXPRESSION = "channel == 'oncall'" + + +@tool( + "await_oncall_reply", + "Wait for the on-call engineer to reply, and return their message.", + {}, +) +async def await_oncall_reply(args: dict[str, Any]) -> dict[str, Any]: + event = await interrupt( + WaitIntegrationEvent( + connector=CONNECTOR, + connection_name=CONNECTION_NAME, + operation=OPERATION, + object_name=OBJECT_NAME, + filter_expression=FILTER_EXPRESSION, + ) + ) + return {"content": [{"type": "text", "text": json.dumps(event, default=str)}]} + + +oncall_server = uipath_tool_server("oncall", tools=[await_oncall_reply]) + + +class EscalationRequest(BaseModel): + incident: str = Field(description="Incident the on-call engineer must answer for.") + + +class EscalationOutcome(BaseModel): + acknowledged: bool = Field( + description="Whether the delivered event acknowledges the incident." + ) + responder: str = Field( + description="Who replied, empty when the event does not name anyone." + ) + next_step: str = Field(description="What the reply says should happen next.") + summary: str = Field(description="One sentence describing the reply.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You escalate incidents to the on-call engineer and wait for their " + "reply.\n" + "Call await_oncall_reply exactly once, on its own and never together " + "with another tool call. It comes back with the engineer's message.\n" + "Read the reply and fill the output. Treat the incident as " + "acknowledged only when the reply actually says someone is taking it, " + "and leave responder empty when the message names nobody." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"oncall": oncall_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=EscalationRequest, + output_schema=EscalationOutcome, + prompt="Incident to escalate: {incident}", +) diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/integration-event-agent/pyproject.toml new file mode 100644 index 00000000..6924995e --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "claude-integration-event-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Integration Event Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/integration-event-agent/uipath.json b/packages/uipath-claude-sdk/samples/integration-event-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/integration-event-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/samples/multi-agent/.env.example b/packages/uipath-claude-sdk/samples/multi-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/multi-agent/README.md b/packages/uipath-claude-sdk/samples/multi-agent/README.md new file mode 100644 index 00000000..3cf56892 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/README.md @@ -0,0 +1,118 @@ +# Multi Agent + +A Claude Agent SDK agent that hands the work to another Orchestrator process and suspends until that job finishes, then writes the briefing from its output. + +## Overview + +Nothing UiPath-specific is injected. The agent declares one tool of its own, and that tool calls `interrupt()` with an `InvokeProcess` imported from `uipath.platform.common`: + +```python +from uipath.platform.common import InvokeProcess +from uipath_claude_sdk import interrupt, uipath_tool_server + +RESEARCH_PROCESS = "company-research-agent" +RESEARCH_FOLDER = "Shared" + + +@tool("run_research", "Research a topic and return the findings.", {"topic": str}) +async def run_research(args: dict[str, Any]) -> dict[str, Any]: + output = await interrupt( + InvokeProcess( + name=RESEARCH_PROCESS, + input_arguments={"topic": args["topic"]}, + process_folder_path=RESEARCH_FOLDER, + ) + ) + return {"content": [{"type": "text", "text": json.dumps(output, default=str)}]} + + +research_server = uipath_tool_server("research", tools=[run_research]) +``` + +`InvokeProcess` is what makes this a JOB resume trigger. The platform decides the trigger kind from the value's type. + +To the model this is just a tool called `run_research` that takes a topic and returns findings. The process name and the folder are deployment configuration and stay module constants, so the model never has to reason about Orchestrator at all. That is what makes the delegation swappable: point the constants at a different process and the agent does not change. + +The invoked process can be anything Orchestrator runs, including another coded agent, an RPA process or a low-code agent, so this is how one agent delegates to another without either of them knowing how the other is built. + +> **Warning:** an agent can invoke itself. Guard against that with an exit condition in the prompt or the input, because a process that keeps invoking itself never terminates. + +## Wait for any, not wait for all + +Pass a **list** to `interrupt()` and it becomes sibling resume triggers under one interrupt id. The run wakes as soon as the **first** of them fires, and the others are deleted. That is a property of the suspend value, not of any particular tool, so the same trick waits for the first of several Action Center actions, or the first of several already running jobs: + +```python +first = await interrupt([CreateTask(...), CreateTask(...)]) +``` + +Each turn suspends at most once, so a tool body can wait for a set of things but never for two independent interrupts. + +## How it works + +1. The input is validated against `BriefingRequest` and the `prompt` template renders the user message +2. The agent calls `mcp__research__run_research` with the topic +3. A `PreToolUse` hook runs the tool body. `interrupt()` raises out of it carrying the `InvokeProcess`, the hook records the pending suspension against the call's `tool_use_id` and defers the call, and the run ends as SUSPENDED with that model as its output +4. UiPath turns it into a JOB resume trigger and starts the process +5. On resume the runtime rebuilds the hook, re-attaches to the same Claude session and runs the body again. This time `interrupt()` returns the job output, and the body's return value is delivered as the parked call's tool result +6. The agent writes the briefing and the result is validated against `Briefing` + +The body runs twice across a suspension, exactly as a LangGraph node does. Anything before the `interrupt()` call happens on both passes, so keep side effects after it or make them idempotent. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + run_research(run_research) + __end__(__end__) + __start__ --> |input|agent + agent --> |suspend|run_research + run_research --> |job output|agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| ----------------------------- | ---------------------------------------------------------------------- | +| `mcp__research__run_research` | Starts the research process and suspends until its job finishes | + +## Input / Output + +```json +// Input +{ + "topic": "UiPath" +} + +// Output +{ + "report": "...", + "sources": ["..."] +} +``` + +## Running the agent + +The process named by `RESEARCH_PROCESS` in `main.py` must be published to the folder named by `RESEARCH_FOLDER`. It is expected to take a `topic` input argument and to return the research as its job output. Change both constants to point at your own process. + +**Initial run (starts the job and suspends):** +```bash +uipath run agent --file input.json +``` + +**Resume once the job has finished:** +```bash +uipath run agent --resume +``` + +The resume needs no payload. A JOB trigger is pollable, so the runtime reads the finished job itself and hands its output to the re-run body. + +## Key features + +- **Delegation across projects**: the invoked process can be any Orchestrator process, coded or low-code +- **Opt-in, not injected**: the delegation lives in the developer's own tool, with the schema and the result mapping they chose +- **The model sees a capability, not plumbing**: the process, the folder and Orchestrator itself stay out of the prompt +- **Wait for any**: a list passed to `interrupt()` becomes sibling triggers resolved by the first to fire +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/multi-agent/agent.mermaid b/packages/uipath-claude-sdk/samples/multi-agent/agent.mermaid new file mode 100644 index 00000000..a05448ba --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/agent.mermaid @@ -0,0 +1,11 @@ +%% AUTO-GENERATED by `uipath init`. Do not edit manually. +%% Regenerated on every `uipath init`. +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ diff --git a/packages/uipath-claude-sdk/samples/multi-agent/claude.json b/packages/uipath-claude-sdk/samples/multi-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/multi-agent/input.json b/packages/uipath-claude-sdk/samples/multi-agent/input.json new file mode 100644 index 00000000..9d932121 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/input.json @@ -0,0 +1,3 @@ +{ + "topic": "UiPath" +} diff --git a/packages/uipath-claude-sdk/samples/multi-agent/main.py b/packages/uipath-claude-sdk/samples/multi-agent/main.py new file mode 100644 index 00000000..06181ad6 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/main.py @@ -0,0 +1,70 @@ +"""Briefing agent that delegates the research to another Orchestrator process.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, tool +from pydantic import BaseModel, Field +from uipath.platform.common import InvokeProcess + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +RESEARCH_PROCESS = "company-research-agent" +RESEARCH_FOLDER = "Shared" + + +@tool( + "run_research", + "Research a topic and return the findings. Takes as long as it takes.", + {"topic": str}, +) +async def run_research(args: dict[str, Any]) -> dict[str, Any]: + output = await interrupt( + InvokeProcess( + name=RESEARCH_PROCESS, + input_arguments={"topic": args["topic"]}, + process_folder_path=RESEARCH_FOLDER, + ) + ) + return {"content": [{"type": "text", "text": json.dumps(output, default=str)}]} + + +research_server = uipath_tool_server("research", tools=[run_research]) + + +class BriefingRequest(BaseModel): + topic: str = Field(description="Company or subject to brief on.") + + +class Briefing(BaseModel): + report: str = Field(description="The briefing, written from the research output.") + sources: list[str] = Field( + default_factory=list, + description="Sources the research named, empty when it named none.", + ) + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You write short company briefings, but you never research anything " + "yourself. Call run_research once with the topic, on its own and " + "never together with another tool call. It comes back with the " + "findings. Write the briefing from them, and do not invent anything " + "the findings do not support." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"research": research_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=BriefingRequest, + output_schema=Briefing, + prompt="Write a briefing on {topic}.", +) diff --git a/packages/uipath-claude-sdk/samples/multi-agent/project.uiproj b/packages/uipath-claude-sdk/samples/multi-agent/project.uiproj new file mode 100644 index 00000000..06513a70 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "claude-multi-agent", + "Description": "UiPath Claude Agent SDK Multi Agent", + "MainFile": null +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/samples/multi-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/multi-agent/pyproject.toml new file mode 100644 index 00000000..d43c5cf3 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "claude-multi-agent" +version = "0.0.2" +description = "UiPath Claude Agent SDK Multi Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/samples/multi-agent/uipath.json b/packages/uipath-claude-sdk/samples/multi-agent/uipath.json new file mode 100644 index 00000000..b1f5e488 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/multi-agent/uipath.json @@ -0,0 +1,3 @@ +{ + "id": "70d9e10e-ec6c-4ec6-827a-62dcea46105e" +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/.env.example b/packages/uipath-claude-sdk/samples/quickstart-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/README.md b/packages/uipath-claude-sdk/samples/quickstart-agent/README.md new file mode 100644 index 00000000..8d160004 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/README.md @@ -0,0 +1,69 @@ +# Quickstart Agent + +A Claude Agent SDK agent that converts an amount between two currencies by calling one deterministic in-process tool, and returns structured output. + +## Overview + +The agent is declared as a `UiPathClaudeAgent`. Passing `uipath_llm=UiPathModel("claude-sonnet-4-5")` is the explicit opt-in that routes every LLM call through the UiPath LLM Gateway, so no Anthropic API key is needed. Leave `uipath_llm` unset and the Claude Agent SDK talks to Anthropic directly with whatever credentials it finds. + +The model id is resolved against the tenant's model discovery at startup, so a friendly name such as `claude-sonnet-4-5` is enough. There is no need to know how the tenant hosts the model. + +## How it works + +1. The input is validated against `ConversionRequest` and the `prompt` template renders the user message +2. The runtime resolves the model, starts the local gateway shim, and points the Claude Code CLI at it +3. The agent loop calls `get_exchange_rate`, which is served in process through `create_sdk_mcp_server`, so no external MCP process is started +4. The result is validated against `ConversionResult` + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| ------------------- | -------------------------------------------------------------- | +| `get_exchange_rate` | Returns the rate between two ISO currency codes (sample rates) | + +## Input / Output + +```json +// Input +{ + "amount": 100, + "from_currency": "EUR", + "to_currency": "RON" +} + +// Output +{ + "converted_amount": 495.45, + "rate_used": 4.9545, + "explanation": "..." +} +``` + +## Running the agent + +```bash +uv run uipath auth +uv run uipath init +uv run uipath run agent --file input.json +``` + +## Key features + +- **Explicit gateway opt-in**: `uipath_llm` decides whether traffic goes to the tenant or straight to Anthropic +- **Model discovery**: a friendly model id is resolved to the id the tenant actually serves +- **In-process tool**: a deterministic `@tool` served by an SDK MCP server, so runs are reproducible +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/agent.mermaid b/packages/uipath-claude-sdk/samples/quickstart-agent/agent.mermaid new file mode 100644 index 00000000..a05448ba --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/agent.mermaid @@ -0,0 +1,11 @@ +%% AUTO-GENERATED by `uipath init`. Do not edit manually. +%% Regenerated on every `uipath init`. +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/claude.json b/packages/uipath-claude-sdk/samples/quickstart-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/input.json b/packages/uipath-claude-sdk/samples/quickstart-agent/input.json new file mode 100644 index 00000000..1676d75d --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/input.json @@ -0,0 +1,5 @@ +{ + "amount": 100, + "from_currency": "EUR", + "to_currency": "RON" +} diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/main.py b/packages/uipath-claude-sdk/samples/quickstart-agent/main.py new file mode 100644 index 00000000..4b929d3d --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/main.py @@ -0,0 +1,63 @@ +"""Currency conversion agent with a deterministic in-process tool.""" + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +RATES_TO_USD = { + "USD": 1.0, + "EUR": 1.09, + "GBP": 1.27, + "RON": 0.22, + "JPY": 0.0067, +} + + +@tool( + "get_exchange_rate", + "Get the exchange rate from one currency to another (ISO codes, e.g. EUR, USD).", + {"from_currency": str, "to_currency": str}, +) +async def get_exchange_rate(args: dict) -> dict: + source = RATES_TO_USD.get(args["from_currency"].upper()) + target = RATES_TO_USD.get(args["to_currency"].upper()) + if source is None or target is None: + return {"content": [{"type": "text", "text": "Unknown currency code."}]} + return {"content": [{"type": "text", "text": str(source / target)}]} + + +currency_server = create_sdk_mcp_server(name="currency", tools=[get_exchange_rate]) + + +class ConversionRequest(BaseModel): + amount: float = Field(description="Amount to convert.") + from_currency: str = Field(description="ISO code to convert from, e.g. 'EUR'.") + to_currency: str = Field(description="ISO code to convert to, e.g. 'RON'.") + + +class ConversionResult(BaseModel): + converted_amount: float = Field( + description="Amount expressed in the target currency." + ) + rate_used: float = Field(description="Rate the tool returned.") + explanation: str = Field(description="One sentence explaining the conversion.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are a currency conversion assistant. " + "Always call the get_exchange_rate tool to look up the rate, and " + "compute the result from the value it returns." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"currency": currency_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ConversionRequest, + output_schema=ConversionResult, + prompt="Convert {amount} {from_currency} to {to_currency}.", +) diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/project.uiproj b/packages/uipath-claude-sdk/samples/quickstart-agent/project.uiproj new file mode 100644 index 00000000..5a5fee56 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "claude-quickstart-agent", + "Description": "UiPath Claude Agent SDK Quickstart Agent", + "MainFile": null +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/quickstart-agent/pyproject.toml new file mode 100644 index 00000000..612fb032 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "claude-quickstart-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Quickstart Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/samples/quickstart-agent/uipath.json b/packages/uipath-claude-sdk/samples/quickstart-agent/uipath.json new file mode 100644 index 00000000..1fb1546f --- /dev/null +++ b/packages/uipath-claude-sdk/samples/quickstart-agent/uipath.json @@ -0,0 +1,3 @@ +{ + "id": "32c2d996-8fbf-41a0-96e1-8cbbd46d853d" +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/.env.example b/packages/uipath-claude-sdk/samples/simple-hitl-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/README.md b/packages/uipath-claude-sdk/samples/simple-hitl-agent/README.md new file mode 100644 index 00000000..924c9ec6 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/README.md @@ -0,0 +1,98 @@ +# Simple HITL Agent + +A Claude Agent SDK agent that suspends durably and waits for a human before it refunds an order. + +## Overview + +Nothing UiPath-specific is injected into this agent. The developer writes two ordinary SDK tools, and one of them, `ask_approver`, calls `interrupt()`: + +```python +@tool("ask_approver", "Ask a human a question and wait for their answer.", {"question": str}) +async def ask_approver(args: dict[str, Any]) -> dict[str, Any]: + answer = await interrupt(args["question"]) + return {"content": [{"type": "text", "text": json.dumps(answer, default=str)}]} + + +approvals_server = uipath_tool_server("approvals", tools=[ask_approver]) +``` + +`uipath_tool_server` returns a plain `McpSdkServerConfig`, registered in `mcp_servers` like any other. `issue_refund` needs no suspension, so it stays in a normal `create_sdk_mcp_server`. + +The value handed to `interrupt()` decides the resume trigger. Here it is a plain string, which the platform turns into an API trigger, so this sample runs with no tenant at all. + +The suspension is durable. The process may end and the machine may go away: the run resumes in a fresh process, and the human's answer arrives at the agent as the result of the very call that was parked, so the conversation continues where it stopped rather than starting over. + +## How it works + +1. The input is validated against `RefundRequest` and the `prompt` template renders the user message +2. The system prompt tells the agent to ask before refunding, so it calls `mcp__approvals__ask_approver` with a question +3. A `PreToolUse` hook runs the tool body. `interrupt()` raises out of it, the hook records the pending suspension against the call's `tool_use_id` and defers the call, and the run ends as SUSPENDED with the question as its output +4. UiPath turns that question into an API resume trigger +5. On resume the runtime rebuilds the hook, re-attaches to the same Claude session and runs the body again. This time `interrupt()` returns the human's answer, the body completes, and its return value is delivered as the parked call's tool result +6. The agent then calls `issue_refund` or refuses, and the result is validated against `RefundOutcome` + +The body runs twice across a suspension, exactly as a LangGraph node does. Anything before the `interrupt()` call happens on both passes, so keep side effects after it or make them idempotent. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + ask_approver(ask_approver) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |suspend|ask_approver + ask_approver --> |human answer|agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| ----------------------------- | --------------------------------------------------------------- | +| `mcp__approvals__ask_approver` | Suspends the run until a human answers the question | +| `mcp__refunds__issue_refund` | Records the refund, only after the human approved it | + +## Input / Output + +```json +// Input +{ + "order_id": "ORD-4711", + "amount": 249.99, + "reason": "The package arrived damaged." +} + +// Output +{ + "approved": true, + "refunded_amount": 249.99, + "summary": "..." +} +``` + +## Running the agent + +**Initial run (suspends and waits for approval):** +```bash +uipath run agent '{"order_id": "ORD-4711", "amount": 249.99, "reason": "The package arrived damaged."}' +``` + +**Resume with the human response:** +```bash +uipath run agent '{"response": "yes"}' --resume +``` + +Whatever JSON you pass on resume is what `interrupt()` returns inside the tool body. + +## Key features + +- **Opt-in, not injected**: the agent has exactly the tools it declares. Drop `uipath_tool_server` and it is an ordinary Claude Agent SDK agent again +- **Durable suspension**: the job can end between the question and the answer, nothing is held open in memory +- **No new prompt on resume**: the answer comes back as the parked tool call's result, so the model keeps its full context +- **Your tool, your contract**: the question, the tool's schema and the shape handed back to the model are all the developer's choice +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/claude.json b/packages/uipath-claude-sdk/samples/simple-hitl-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/input.json b/packages/uipath-claude-sdk/samples/simple-hitl-agent/input.json new file mode 100644 index 00000000..3c42cfd7 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/input.json @@ -0,0 +1,5 @@ +{ + "order_id": "ORD-4711", + "amount": 249.99, + "reason": "The package arrived damaged." +} diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/main.py b/packages/uipath-claude-sdk/samples/simple-hitl-agent/main.py new file mode 100644 index 00000000..7eaf55c0 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/main.py @@ -0,0 +1,80 @@ +"""Refund agent that asks a human for approval before it moves any money.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +LEDGER: dict[str, float] = {} + + +@tool( + "ask_approver", + "Ask a human a question and wait for their answer.", + {"question": str}, +) +async def ask_approver(args: dict[str, Any]) -> dict[str, Any]: + answer = await interrupt(args["question"]) + return {"content": [{"type": "text", "text": json.dumps(answer, default=str)}]} + + +@tool( + "issue_refund", + "Refund an order. Never call this before a human has approved the refund.", + {"order_id": str, "amount": float}, +) +async def issue_refund(args: dict[str, Any]) -> dict[str, Any]: + LEDGER[args["order_id"]] = args["amount"] + return { + "content": [ + { + "type": "text", + "text": f"Refunded {args['amount']} on order {args['order_id']}.", + } + ] + } + + +approvals_server = uipath_tool_server("approvals", tools=[ask_approver]) +refunds_server = create_sdk_mcp_server(name="refunds", tools=[issue_refund]) + + +class RefundRequest(BaseModel): + order_id: str = Field(description="Order the customer wants refunded.") + amount: float = Field(description="Amount the customer is asking back.") + reason: str = Field(description="Why the customer is asking for a refund.") + + +class RefundOutcome(BaseModel): + approved: bool = Field(description="Whether the human approved the refund.") + refunded_amount: float = Field(description="Amount refunded, 0 when refused.") + summary: str = Field(description="One sentence describing what happened.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You process customer refund requests. You never refund anything on " + "your own authority. Call ask_approver first, with a question naming " + "the order, the amount and the reason. Make that call on its own, " + "never together with another tool call. Call issue_refund only if the " + "answer approves the refund, and leave the order untouched otherwise." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"approvals": approvals_server, "refunds": refunds_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=RefundRequest, + output_schema=RefundOutcome, + prompt="Refund request for order {order_id}: {amount} back, because {reason}.", +) diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/simple-hitl-agent/pyproject.toml new file mode 100644 index 00000000..cbe3f2cf --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "claude-simple-hitl-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Simple HITL Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/simple-hitl-agent/uipath.json b/packages/uipath-claude-sdk/samples/simple-hitl-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-hitl-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/.env.example b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/README.md b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/README.md new file mode 100644 index 00000000..7d4268a3 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/README.md @@ -0,0 +1,105 @@ +# Simple Local MCP Agent + +A Claude Agent SDK agent that runs its own MCP server as a local subprocess over stdio, and returns structured output. + +## Overview + +`ledger_server.py` is a stdio MCP server holding an in-memory expense ledger. The Claude Code CLI starts it with the command and arguments given in `mcp_servers`, keeps it alive for the run, and exposes its tools to the model as `mcp__ledger__*`. + +The agent is told to record each expense line through the server and to read the total back from it rather than adding the numbers itself, so the arithmetic is deterministic and the model only does the parsing and the wording. + +A stdio server is a plain subprocess, so the config is just how to launch it: + +```python +mcp_servers={ + "ledger": { + "type": "stdio", + "command": sys.executable, + "args": [str(LEDGER_SERVER)], + } +} +``` + +`sys.executable` is the interpreter running the agent, which is the one that already has the dependencies. The path is derived from `__file__` because the working directory of a published run is not the sample directory. + +`ledger_server.py` imports only `mcp`, which the Claude Agent SDK already depends on, so nothing extra has to be installed. Log lines go to stderr because stdout carries the MCP protocol. + +## Stdio, SDK, or remote + +| Transport | When it fits | +| --------- | ------------ | +| `stdio` (this sample) | An existing MCP server binary or script, its own process and dependencies, restarted per run | +| `sdk` (`create_sdk_mcp_server`, see the quickstart agent) | Tools you write in the agent module itself, no subprocess | +| `http` / `sse` (see the remote MCP agent) | A server someone else hosts, including UiPath-hosted ones | + +## Validating the mapping + +The runtime adds no MCP server of its own, so every entry in `mcp_servers` is one you wrote. The key `uipath` is still refused at startup. Call `uipath_claude_sdk.mcp.validate_mcp_servers(...)` on the mapping to catch that, and a malformed entry, when the module is imported rather than when the run starts. + +## How it works + +1. The input is validated against `ExpenseReport` and the `prompt` template renders the user message +2. The runtime resolves the model, starts the local gateway shim, and points the Claude Code CLI at it +3. The CLI spawns `ledger_server.py` and lists its tools +4. The agent calls `add_entry` once per line, then `total` and `largest_entry` +5. The result is validated against `ExpenseSummary` + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(mcp__ledger__*) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ +``` + +## Tools + +| Tool | Description | +| --------------- | ---------------------------------------------------- | +| `add_entry` | Records one expense under a label | +| `list_entries` | Returns every recorded expense | +| `total` | Returns the sum of every recorded expense | +| `largest_entry` | Returns the label of the most expensive entry | + +## Input / Output + +```json +// Input +{ + "expenses": [ + "Flight to Bucharest: 412.30", + "Hotel, three nights: 268.00", + "Taxi from the airport: 34.50", + "Team dinner: 156.75" + ], + "currency": "EUR" +} + +// Output +{ + "total": 871.55, + "largest": "Flight to Bucharest", + "explanation": "..." +} +``` + +## Running the agent + +```bash +uv run uipath auth +uv run uipath init +uv run uipath run agent --file input.json +``` + +## Key features + +- **Local stdio MCP server**: a real subprocess, launched and torn down by the CLI +- **Deterministic arithmetic**: the total comes from the server, not from the model +- **No extra dependencies**: the server uses `mcp`, which the Claude Agent SDK already brings +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/claude.json b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/input.json b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/input.json new file mode 100644 index 00000000..4aa76f25 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/input.json @@ -0,0 +1,9 @@ +{ + "expenses": [ + "Flight to Bucharest: 412.30", + "Hotel, three nights: 268.00", + "Taxi from the airport: 34.50", + "Team dinner: 156.75" + ], + "currency": "EUR" +} diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/ledger_server.py b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/ledger_server.py new file mode 100644 index 00000000..c2d04e28 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/ledger_server.py @@ -0,0 +1,43 @@ +"""A tiny stdio MCP server keeping an in-memory expense ledger. + +Started as a subprocess by the Claude Code CLI, it speaks MCP over stdin and +stdout for the lifetime of the run. It uses only ``mcp``, which the Claude +Agent SDK already depends on, so the sample needs no extra install. +""" + +from mcp.server.fastmcp import FastMCP + +server = FastMCP("ledger") + +ENTRIES: dict[str, float] = {} + + +@server.tool() +def add_entry(label: str, amount: float) -> str: + """Record one expense in the ledger, replacing any entry with the same label.""" + ENTRIES[label] = amount + return f"Recorded {label} at {amount:.2f}." + + +@server.tool() +def list_entries() -> dict[str, float]: + """Return every recorded expense, keyed by label.""" + return dict(ENTRIES) + + +@server.tool() +def total() -> float: + """Return the sum of every recorded expense.""" + return round(sum(ENTRIES.values()), 2) + + +@server.tool() +def largest_entry() -> str: + """Return the label of the most expensive recorded entry.""" + if not ENTRIES: + return "The ledger is empty." + return max(ENTRIES, key=lambda label: ENTRIES[label]) + + +if __name__ == "__main__": + server.run() diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/main.py b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/main.py new file mode 100644 index 00000000..e9b4bf11 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/main.py @@ -0,0 +1,50 @@ +"""Agent whose tools come from a local stdio MCP server run as a subprocess.""" + +import sys +from pathlib import Path + +from claude_agent_sdk import ClaudeAgentOptions +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +LEDGER_SERVER = Path(__file__).parent / "ledger_server.py" + + +class ExpenseReport(BaseModel): + expenses: list[str] = Field( + description="Expense lines, each naming a label and an amount." + ) + currency: str = Field(default="EUR", description="Currency the amounts are in.") + + +class ExpenseSummary(BaseModel): + total: float = Field(description="Sum of every expense, as the ledger computed it.") + largest: str = Field(description="Label of the most expensive entry.") + explanation: str = Field(description="One sentence describing the report.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You summarize expense reports. Record every expense line with " + "add_entry, one call per line, then read the numbers back with " + "total and largest_entry. Never add the amounts up yourself: the " + "ledger is the only source of truth for the total." + ), + max_turns=15, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={ + "ledger": { + "type": "stdio", + "command": sys.executable, + "args": [str(LEDGER_SERVER)], + } + }, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ExpenseReport, + output_schema=ExpenseSummary, + prompt="Summarize these expenses, in {currency}: {expenses}", +) diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/pyproject.toml new file mode 100644 index 00000000..936eb904 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "claude-simple-local-mcp-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Local MCP Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/uipath.json b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-local-mcp-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/.env.example b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/.env.example new file mode 100644 index 00000000..73cbc1cf --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/.env.example @@ -0,0 +1,5 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx +UIPATH_MCP_SERVER_URL=xxx diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/README.md b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/README.md new file mode 100644 index 00000000..8f318716 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/README.md @@ -0,0 +1,95 @@ +# Simple Remote MCP Agent + +A Claude Agent SDK agent whose entire toolset comes from a remote MCP server reached over streamable HTTP, and that returns structured output. + +## Overview + +MCP support is part of the Claude Agent SDK: anything placed in `ClaudeAgentOptions.mcp_servers` is connected by the Claude Code CLI, and its tools show up to the model as `mcp____`. This sample declares one entry, `remote`, so the model sees the server's tools as `mcp__remote__*`. + +`remote_mcp_server` only builds that entry: it produces `{"type": "http", "url": ..., "headers": {"Authorization": "Bearer ..."}}` and fills the bearer token from `UIPATH_ACCESS_TOKEN` when you do not pass one. Pass `transport="sse"` for a server that speaks server-sent events instead. + +## Substitute the server URL + +The sample reads the endpoint from `UIPATH_MCP_SERVER_URL` and falls back to `https://REPLACE-ME.example.com/mcp`, which is a placeholder and connects to nothing. Set the variable in `.env` before running. + +For a UiPath-hosted MCP server, the endpoint is a per-server value the tenant returns rather than a URL you can assemble by hand. Read it once with the UiPath SDK: + +```python +from uipath.platform import UiPath + +sdk = UiPath() +for server in sdk.mcp.list(folder_path="Shared"): + print(server.name, server.slug) + +print(sdk.mcp.retrieve("your-server-slug", folder_path="Shared").mcp_url) +``` + +You can also let the agent resolve it at definition time: + +```python +from uipath_claude_sdk.mcp import uipath_mcp_server + +mcp_servers={"remote": uipath_mcp_server("your-server-slug", folder_path="Shared")} +``` + +That reads the endpoint and the credential from the tenant, at the cost of a network call every time the module is imported, which includes `uipath init`. The env-var form above keeps packaging offline. + +## Validating the mapping + +The runtime adds no MCP server of its own, so every entry in `mcp_servers` is one you wrote. The key `uipath` is still refused at startup. Call `uipath_claude_sdk.mcp.validate_mcp_servers(...)` on the mapping to catch that, and a malformed entry, when the module is imported rather than when the run starts. + +## How it works + +1. The input is validated against `ServerRequest` and the `prompt` template renders the user message +2. The runtime resolves the model, starts the local gateway shim, and points the Claude Code CLI at it +3. The CLI opens the HTTP connection to the MCP server and lists its tools +4. The agent loop calls whichever `mcp__remote__*` tools fit the request +5. The result is validated against `ServerAnswer` + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(mcp__remote__*) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ +``` + +## Tools + +The server owns the tool list, so there is nothing to declare here. Whatever it advertises becomes `mcp__remote__`. + +## Input / Output + +```json +// Input +{ + "request": "List the tools you have and use them to summarize what this server can do." +} + +// Output +{ + "answer": "...", + "tools_used": ["mcp__remote__..."] +} +``` + +## Running the agent + +```bash +uv run uipath auth +uv run uipath init +uv run uipath run agent --file input.json +``` + +## Key features + +- **Remote MCP over HTTP**: one `mcp_servers` entry is the whole integration, no client code +- **Tenant auth**: the bearer token is applied for you from `UIPATH_ACCESS_TOKEN` +- **Explicit gateway opt-in**: `uipath_llm` decides whether traffic goes to the tenant or straight to Anthropic +- **Structured output**: input and output are Pydantic models, validated on both ends diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/claude.json b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/input.json b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/input.json new file mode 100644 index 00000000..b074fc76 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/input.json @@ -0,0 +1,3 @@ +{ + "request": "List the tools you have and use them to summarize what this server can do." +} diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/main.py b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/main.py new file mode 100644 index 00000000..13c5a08d --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/main.py @@ -0,0 +1,46 @@ +"""Agent whose tools come from a remote MCP server over streamable HTTP.""" + +import os + +from claude_agent_sdk import ClaudeAgentOptions +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel +from uipath_claude_sdk.mcp import remote_mcp_server + +MCP_SERVER_URL = os.getenv( + "UIPATH_MCP_SERVER_URL", + "https://REPLACE-ME.example.com/mcp", +) + + +class ServerRequest(BaseModel): + request: str = Field(description="What the user wants the server to do.") + + +class ServerAnswer(BaseModel): + answer: str = Field(description="Answer written from the tool results.") + tools_used: list[str] = Field( + default_factory=list, + description="Names of the MCP tools that were called, in call order.", + ) + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You answer requests using only the tools exposed by the 'remote' " + "MCP server. Inspect the tools you have, pick the ones that fit the " + "request, and never guess a value a tool could have told you. " + "List every tool you called in tools_used." + ), + max_turns=12, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"remote": remote_mcp_server(MCP_SERVER_URL)}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ServerRequest, + output_schema=ServerAnswer, + prompt="{request}", +) diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/pyproject.toml b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/pyproject.toml new file mode 100644 index 00000000..5a5b8c63 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "claude-simple-remote-mcp-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Remote MCP Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/uipath.json b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/samples/simple-remote-mcp-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/scripts/pack_sample.sh b/packages/uipath-claude-sdk/scripts/pack_sample.sh new file mode 100755 index 00000000..552731fb --- /dev/null +++ b/packages/uipath-claude-sdk/scripts/pack_sample.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Pack a sample against the local uipath-claude-sdk instead of a published release. +# +# ./scripts/pack_sample.sh samples/quickstart-agent +# +# The samples resolve uipath-claude-sdk from this checkout with an editable path +# dependency, which is right for `uipath run` and wrong for `uipath pack`: the +# path points outside the project directory, so the packer never sees it and the +# executor cannot install it. This builds a wheel into the sample's gitignored +# wheels/ directory, points the sample at that wheel for the duration of the +# pack, and restores every file it touched. +# +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +pkg_root=$(cd "$(dirname "$0")/.." && pwd) +sample=$(cd "$1" && pwd) + +for required in pyproject.toml uipath.json entry-points.json; do + if [ ! -f "$sample/$required" ]; then + echo "$sample has no $required. Run 'uipath init' there first." >&2 + exit 1 + fi +done + +uv build --wheel --project "$pkg_root" --out-dir "$sample/wheels" +wheel=$(cd "$sample" && ls -t wheels/uipath_claude_sdk-*.whl | head -1) + +backup=$(mktemp -d) +restore() { + for f in pyproject.toml uipath.json uv.lock; do + [ -f "$backup/$f" ] && cp "$backup/$f" "$sample/$f" + done + rm -rf "$backup" + # Packing installs the wheel over the editable checkout, so without this the + # sample would keep running a frozen copy of the source until the next sync. + (cd "$sample" && uv sync --quiet) || true +} +trap restore EXIT + +for f in pyproject.toml uipath.json uv.lock; do + [ -f "$sample/$f" ] && cp "$sample/$f" "$backup/$f" +done + +cd "$sample" + +python3 - "$wheel" <<'PY' +import json +import re +import sys + +wheel = sys.argv[1] + +source = open("pyproject.toml").read() +pattern = re.compile(r'^uipath-claude-sdk\s*=\s*\{[^}]*\}$', re.MULTILINE) +if not pattern.search(source): + sys.exit( + "pyproject.toml has no [tool.uv.sources] entry for uipath-claude-sdk, " + "so there is nothing to redirect at the local wheel." + ) +open("pyproject.toml", "w").write( + pattern.sub(f'uipath-claude-sdk = {{ path = "{wheel}" }}', source) +) + +config = json.load(open("uipath.json")) +options = config.setdefault("packOptions", {}) +extensions = options.setdefault("fileExtensionsIncluded", []) +if ".whl" not in extensions: + extensions.append(".whl") +json.dump(config, open("uipath.json", "w"), indent=2) +PY + +uv lock +uv run uipath pack + +echo +echo "Packed with $wheel vendored into the package." +echo "The sample's own files are back as they were." diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/__init__.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/__init__.py new file mode 100644 index 00000000..918ae228 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/__init__.py @@ -0,0 +1,47 @@ +"""UiPath Claude Agent SDK Integration.""" + +from typing import Any + +from .agent import ClaudeAgent, UiPathClaudeAgent +from .interrupts import ( + InterruptOutsideRunError, + PendingSuspend, + SuspendAlreadyPendingError, + SuspendChannel, + SuspendChannelError, + UnknownInterruptError, + interrupt, + uipath_tool_server, +) +from .models import UiPathModel + +__all__ = [ + "ClaudeAgent", + "InterruptOutsideRunError", + "PendingSuspend", + "SuspendAlreadyPendingError", + "SuspendChannel", + "SuspendChannelError", + "UiPathClaudeAgent", + "UiPathModel", + "UnknownInterruptError", + "interrupt", + "register_middleware", + "uipath_tool_server", +] + + +def __getattr__(name: str) -> Any: + if name == "register_middleware": + from .middlewares import register_middleware + + return register_middleware + if name == "UiPathClaudeSDKRuntimeFactory": + from .runtime.factory import UiPathClaudeSDKRuntimeFactory + + return UiPathClaudeSDKRuntimeFactory + if name == "UiPathClaudeSDKRuntime": + from .runtime.runtime import UiPathClaudeSDKRuntime + + return UiPathClaudeSDKRuntime + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/__init__.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/claude.json.template b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/claude.json.template new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/claude.json.template @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/main.py.template b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/main.py.template new file mode 100644 index 00000000..ed7e9aad --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/_templates/main.py.template @@ -0,0 +1,65 @@ +"""Claude Agent SDK weather agent with a custom in-process tool.""" + +import json + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +READINGS = { + "london": {"temperature_celsius": 14.0, "conditions": "overcast"}, + "paris": {"temperature_celsius": 18.0, "conditions": "partly cloudy"}, + "new york": {"temperature_celsius": 22.0, "conditions": "clear sky"}, + "tokyo": {"temperature_celsius": 26.0, "conditions": "sunny"}, + "sydney": {"temperature_celsius": 19.0, "conditions": "light rain"}, +} + + +@tool( + "get_weather", + "Get the current weather reading for a city, e.g. London.", + {"city": str}, +) +async def get_weather(args: dict) -> dict: + reading = READINGS.get(args["city"].lower().strip()) + if reading is None: + text = f"No weather reading available for {args['city']}." + else: + text = json.dumps(reading) + return {"content": [{"type": "text", "text": text}]} + + +weather_server = create_sdk_mcp_server( + name="weather", + tools=[get_weather], +) + + +class WeatherRequest(BaseModel): + city: str = Field(description="City to report the weather for, e.g. 'London'.") + + +class WeatherReport(BaseModel): + city: str = Field(description="City the reading refers to.") + temperature_celsius: float = Field(description="Temperature returned by the tool.") + summary: str = Field(description="One sentence description of the weather.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are a weather assistant. " + "Always call the get_weather tool before answering, and report only " + "the values it returns." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"weather": weather_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=WeatherRequest, + output_schema=WeatherReport, + prompt="Report the current weather in {city}.", +) diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/cli_new.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/cli_new.py new file mode 100644 index 00000000..09583c2e --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/_cli/cli_new.py @@ -0,0 +1,72 @@ +import os +import shutil + +import click +from uipath._cli._utils._console import ConsoleLogger +from uipath._cli.middlewares import MiddlewareResult + +console = ConsoleLogger() + + +def generate_script(target_directory): + template_script_path = os.path.join( + os.path.dirname(__file__), "_templates/main.py.template" + ) + target_path = os.path.join(target_directory, "main.py") + + shutil.copyfile(template_script_path, target_path) + + template_claude_json_path = os.path.join( + os.path.dirname(__file__), "_templates/claude.json.template" + ) + target_path = os.path.join(target_directory, "claude.json") + shutil.copyfile(template_claude_json_path, target_path) + + +def generate_pyproject(target_directory, project_name): + project_toml_path = os.path.join(target_directory, "pyproject.toml") + toml_content = f"""[project] +name = "{project_name}" +version = "0.0.1" +description = "{project_name}" +authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] +dependencies = [ + "uipath-claude-sdk>=0.1.0, <0.2.0", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev>=0.0.74", +] +""" + + with open(project_toml_path, "w") as f: + f.write(toml_content) + + +def claude_new_middleware(name: str) -> MiddlewareResult: + """Middleware to create a demo Claude Agent SDK agent""" + + directory = os.getcwd() + + try: + with console.spinner(f"Creating new agent {name} in current directory ..."): + generate_script(directory) + console.success("Created 'main.py' file.") + console.success("Created 'claude.json' file.") + generate_pyproject(directory, name) + console.success("Created 'pyproject.toml' file.") + init_command = """uipath init""" + run_command = """uipath run agent '{"city": "London"}'""" + console.hint( + f""" Initialize project: {click.style(init_command, fg="cyan")}""" + ) + console.hint(f""" Run agent: {click.style(run_command, fg="cyan")}""") + return MiddlewareResult(should_continue=False) + except Exception as e: + console.error(f"Error creating demo agent {str(e)}") + return MiddlewareResult( + should_continue=False, + should_include_stacktrace=True, + ) diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/agent.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/agent.py new file mode 100644 index 00000000..7c906909 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/agent.py @@ -0,0 +1,51 @@ +"""User-facing agent definition for the Claude Agent SDK integration. + +Developers export a ``UiPathClaudeAgent`` (or a bare ``ClaudeAgentOptions``) +from their entrypoint module. The UiPath runtime owns the run loop +(``ClaudeSDKClient.query()`` / ``receive_response()``) so it can map SDK +messages to UiPath runtime events. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from claude_agent_sdk import ClaudeAgentOptions +from pydantic import BaseModel + +from .models import UiPathModel + + +@dataclass +class UiPathClaudeAgent: + """Declarative agent definition executed by the UiPath Claude SDK runtime. + + Args: + options: Claude Agent SDK options (model, system prompt, tools, + MCP servers, permission mode, ...). The runtime injects + execution-scoped fields (cwd, env, output_format, resume). + uipath_llm: Routing descriptor for a model served by the UiPath LLM + Gateway. When set, the runtime starts a local gateway shim and + points the Claude SDK at it. When left as ``None``, the runtime + injects nothing and the Claude SDK talks to Anthropic directly + with whatever credentials it finds in the environment. + input_schema: Optional Pydantic model describing structured input. + When set, the runtime validates input against it and renders + ``prompt`` from its fields. + output_schema: Optional Pydantic model describing structured output. + When set, the runtime requests native structured output from the + SDK (``output_format=json_schema``) and returns the parsed result. + prompt: Optional user-message template rendered with ``str.format`` + from the validated input fields (e.g. ``"Summarize {topic}"``). + name: Display name used in the runtime graph. + """ + + options: ClaudeAgentOptions = field(default_factory=ClaudeAgentOptions) + uipath_llm: UiPathModel | None = None + input_schema: type[BaseModel] | None = None + output_schema: type[BaseModel] | None = None + prompt: str | None = None + name: str = "agent" + + +ClaudeAgent = UiPathClaudeAgent diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/interrupts.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/interrupts.py new file mode 100644 index 00000000..c8946901 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/interrupts.py @@ -0,0 +1,625 @@ +"""Opt-in interrupt surface for a Claude Agent SDK agent running on UiPath. + +Nothing here is installed unless the developer asks for it. An agent whose +``mcp_servers`` holds no server built by :func:`uipath_tool_server` runs with +exactly the tools, hooks and prompt it declared, and an agent that runs +elsewhere runs unchanged on UiPath. + +The developer writes an ordinary SDK tool and calls :func:`interrupt` inside it +with a platform model imported from ``uipath.platform.common``:: + + from uipath.platform.common import CreateTask + from uipath_claude_sdk import interrupt, uipath_tool_server + + @tool("review", "Get a human to review it.", {"ticket_id": str, "label": str}) + async def review(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + app_name="escalation_agent_app", + title="Action Required: Review classification", + data={"AgentOutput": f"Classified {args['ticket_id']}"}, + app_folder_path="Shared", + ) + ) + approved = action_data["Answer"] is True + return {"content": [{"type": "text", "text": f"Approved: {approved}"}]} + + server = uipath_tool_server("tickets", tools=[review]) + options = ClaudeAgentOptions(mcp_servers={"tickets": server}) + +The value handed to :func:`interrupt` reaches the platform untouched. +``UiPathResumeTriggerHandler`` decides the trigger kind from the value's type, +so every platform interrupt model works without any code here knowing about it, +and a value it does not recognise degrades to an API trigger. A list becomes +sibling triggers for one interrupt, resolved by whichever fires first. + +How the body runs +----------------- + +A tool handler cannot pause and resume across a process boundary, and the CLI's +``defer`` decision fires before the handler runs. The tool body is therefore +executed by the ``PreToolUse`` hook, and the handler returns what the hook +already computed for that ``tool_use_id``: + +* Suspend pass. :func:`interrupt` raises a private carrier, the hook records the + pending suspension against the CLI's ``tool_use_id`` and defers the call. +* Resume pass. The CLI re-issues the same ``tool_use_id``. :func:`interrupt` + returns the resolved payload, the body runs to completion, and the handler + delivers its return value instead of executing a second time. +* No interrupt at all. The body completes on the first pass and the handler + delivers its return value. The body still runs exactly once. + +Across a suspension the body therefore runs twice, exactly as a LangGraph node +does. Work performed BEFORE the :func:`interrupt` call happens twice, so keep +side effects after the call or make them idempotent. +""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +from collections import deque +from collections.abc import Awaitable, Callable, Iterator, Mapping +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 +from weakref import WeakKeyDictionary + +from claude_agent_sdk import ( + McpSdkServerConfig, + SdkMcpTool, + create_sdk_mcp_server, +) +from opentelemetry import context as context_api +from opentelemetry.context import Context +from pydantic import BaseModel +from uipath.core.tracing import traced +from uipath.platform.common import interrupt_models + +__all__ = [ + "INTERRUPT_SUSPEND_MODELS", + "UIPATH_MCP_SERVER_NAME", + "InterruptToolBinding", + "InterruptOutsideRunError", + "PendingSuspend", + "SuspendAlreadyPendingError", + "SuspendChannel", + "SuspendChannelError", + "ToolIndex", + "ToolOutcome", + "UnknownInterruptError", + "active_channel", + "call_key", + "interrupt", + "run_tool_body", + "uipath_tool_index", + "uipath_tool_server", +] + +logger = logging.getLogger(__name__) + +UIPATH_MCP_SERVER_NAME = "uipath" + +ToolBody = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]] + +_NOT_DEFERRED_MESSAGE = ( + "No resumed input is available for this call, so the run was never " + "suspended. Do not retry the same call. Continue with the information you " + "already have, or tell the user that waiting for input is unavailable." +) + +_OUTSIDE_RUN_MESSAGE = ( + "interrupt() was called outside a UiPath run, so there is no channel to " + "suspend on. A tool can only suspend when its server was built with " + "uipath_tool_server() and registered in ClaudeAgentOptions.mcp_servers, " + "and when the agent is executed by the UiPath Claude SDK runtime." +) + + +class SuspendChannelError(RuntimeError): + """Base error for misuse of a :class:`SuspendChannel`.""" + + +class SuspendAlreadyPendingError(SuspendChannelError): + """Raised when a second suspend is claimed while one is already in flight.""" + + +class UnknownInterruptError(SuspendChannelError): + """Raised when an interrupt id does not match the channel's pending record.""" + + +class InterruptOutsideRunError(SuspendChannelError): + """Raised when :func:`interrupt` is called with no UiPath run around it.""" + + +INTERRUPT_SUSPEND_MODELS: dict[str, type[BaseModel]] = { + name: value + for name, value in vars(interrupt_models).items() + if inspect.isclass(value) + and issubclass(value, BaseModel) + and value is not BaseModel +} +"""Every platform interrupt model, keyed by its class name. + +A suspend value that crosses a suspension is serialized, and the platform's +trigger creator dispatches on the value's type, so a value reloaded as a plain +dict silently becomes an API trigger. Persisting the class name and looking it +up here rebuilds the type the creator needs. +""" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _identifying_inputs_only(inputs: dict[str, Any]) -> dict[str, Any]: + """Name the suspension on its span without recording what it carries. + + A suspend value is whatever the tool passed to :func:`interrupt`: an + approval's payload, a question, a record to write. It reaches LLM Ops as a + span attribute if it is recorded, so only the identifiers go there. + """ + return { + name: inputs.get(name) + for name in ("tool_name", "tool_use_id") + if name in inputs + } + + +class _InterruptRaised(BaseException): + """Carries a suspend value out of a tool body on the suspend pass. + + It derives from ``BaseException`` so a developer's ``except Exception`` + around their own work cannot swallow a suspension. + """ + + def __init__(self, value: Any) -> None: + super().__init__("A UiPath interrupt escaped its tool body.") + self.value = value + + +@dataclass(frozen=True) +class _CallContext: + """What :func:`interrupt` needs to decide between raising and returning.""" + + channel: SuspendChannel + tool_use_id: str | None + + +@dataclass +class ToolOutcome: + """What a tool body produced, held until the handler delivers it.""" + + result: dict[str, Any] | None = None + error: BaseException | None = None + tool_use_id: str | None = None + + def deliver(self) -> dict[str, Any]: + if self.error is not None: + raise self.error + return self.result or {} + + +@dataclass(frozen=True) +class InterruptToolBinding: + """One developer tool the suspend hook is allowed to execute. + + Attributes: + token: Opaque per-tool identifier, so a stashed result cannot be picked + up by a same-named tool from another server. + body: The handler the developer wrote, before wrapping. + """ + + token: str + body: ToolBody + + +ToolIndex = dict[str, InterruptToolBinding] + + +@dataclass +class PendingSuspend: + """One suspension awaiting a resume payload. + + Attributes: + interrupt_id: Identifier the runtime reports in the SUSPENDED output map + and that the resumed process seeds a payload against. It is the + CLI's ``tool_use_id``, which is stable across defer and re-issue. + tool_name: Model-visible tool name, e.g. ``mcp__tickets__review``. + value: What lands in the SUSPENDED output map for this interrupt. The + platform's trigger creator decides the trigger kind from its type, + and a list yields sibling triggers resolved by the first to fire. + tool_use_id: The CLI's tool_use id for the deferred call, equal to + ``interrupt_id`` and kept as its own field for the stored record. + requested_at: ISO 8601 UTC timestamp of the claim. + """ + + interrupt_id: str + tool_name: str + value: Any + tool_use_id: str | None = None + requested_at: str = field(default_factory=_utc_now_iso) + + +class SuspendChannel: + """Per-run rendezvous between the PreToolUse hook, the tools and the runtime. + + The hook runs a developer tool body and leaves behind either a pending + suspension or the body's outcome. The runtime reads :attr:`pending` to build + its SUSPENDED result. On resume a fresh channel is restored from the + persisted record and seeded with the trigger payload, which the re-run body + receives from :func:`interrupt`. + """ + + def __init__(self) -> None: + self.pending: PendingSuspend | None = None + self.deferrals_requested = 0 + self.turn_parent: Context | None = None + self._resolved: dict[str, Any] = {} + self._outcomes: dict[str, deque[ToolOutcome]] = {} + self._lock = asyncio.Lock() + + def adopt_turn_parent(self, parent: Context) -> None: + """Record the trace context a tool body should run under. + + The runtime calls this while the turn's agent span is current. Only + valid from there, and it moves on with the turn. + """ + self.turn_parent = parent + + @traced( + name="claude_suspend_claim", + run_type="uipath", + input_processor=_identifying_inputs_only, + ) + async def claim(self, tool_name: str, value: Any, tool_use_id: str) -> str: + """Register a new suspension and return its interrupt id. + + Args: + tool_name: Model-visible name of the tool being deferred. + value: The suspend value for the SUSPENDED output map. + tool_use_id: The CLI's tool_use id for this call. + + Returns: + The interrupt id, which is ``tool_use_id``. + + Raises: + SuspendAlreadyPendingError: If a different suspension is in flight. + """ + async with self._lock: + if self.pending is not None and self.pending.interrupt_id != tool_use_id: + raise SuspendAlreadyPendingError( + f"Interrupt {self.pending.interrupt_id} for " + f"{self.pending.tool_name} is already in flight." + ) + self.deferrals_requested += 1 + self.pending = PendingSuspend( + interrupt_id=tool_use_id, + tool_name=tool_name, + value=value, + tool_use_id=tool_use_id, + ) + return tool_use_id + + def restore(self, pending: PendingSuspend) -> None: + """Reinstate a persisted pending record in a resumed process. + + Args: + pending: The record persisted when the run suspended. + + Raises: + SuspendAlreadyPendingError: If a suspension is already in flight. + """ + if self.pending is not None: + raise SuspendAlreadyPendingError( + f"Interrupt {self.pending.interrupt_id} is already in flight." + ) + self.pending = pending + + def resolve(self, interrupt_id: str, payload: Any) -> None: + """Seed the trigger payload for a restored suspension. + + Args: + interrupt_id: Identifier of the pending record. + payload: What the re-run body receives from :func:`interrupt`. + + Raises: + UnknownInterruptError: If no pending record carries that id. + """ + if self.pending is None or self.pending.interrupt_id != interrupt_id: + raise UnknownInterruptError( + f"No pending suspension with interrupt id {interrupt_id}." + ) + self._resolved[interrupt_id] = payload + + def resolved_for(self, interrupt_id: str | None) -> Any | None: + """Return the seeded payload, or ``None`` when there is none.""" + if interrupt_id is None: + return None + return self._resolved.get(interrupt_id) + + def is_resolved(self, interrupt_id: str | None) -> bool: + """Whether a payload has been seeded for ``interrupt_id``.""" + return interrupt_id is not None and interrupt_id in self._resolved + + def complete(self, interrupt_id: str) -> None: + """Drop the pending record and its payload once delivered to the model.""" + self._resolved.pop(interrupt_id, None) + if self.pending is not None and self.pending.interrupt_id == interrupt_id: + self.pending = None + + def stash(self, key: str, outcome: ToolOutcome) -> None: + """Hold what a tool body produced until its handler is called. + + One key can hold several outcomes. A model is free to make the same call + twice in one turn, and the key is built from the tool and its arguments + because that is all a handler has to recognise itself by: the SDK hands + a handler its arguments and no ``tool_use_id``. Holding a queue rather + than a slot is what stops the second of two identical calls from finding + nothing stashed and running the body again. + """ + self._outcomes.setdefault(key, deque()).append(outcome) + + def take(self, key: str) -> ToolOutcome | None: + """Remove and return the oldest stashed outcome, or ``None`` when empty.""" + queued = self._outcomes.get(key) + if not queued: + return None + outcome = queued.popleft() + if not queued: + del self._outcomes[key] + return outcome + + +_ACTIVE_CHANNEL: ContextVar[SuspendChannel | None] = ContextVar( + "uipath_claude_sdk_active_channel", default=None +) + +_ACTIVE_CALL: ContextVar[_CallContext | None] = ContextVar( + "uipath_claude_sdk_active_call", default=None +) + + +@contextmanager +def _under_turn(channel: SuspendChannel) -> Iterator[None]: + """Run a tool body inside the trace context of the turn that called it. + + The client's tasks inherit the context from before the run began, so a span + a tool opens would otherwise attach to whatever was current back then and + surface outside the turn rather than under it. The runtime records the turn + on the channel, which is the same handoff the gateway spans use. + """ + parent = channel.turn_parent + if parent is None: + yield + return + token = context_api.attach(parent) + try: + yield + finally: + context_api.detach(token) + + +@contextmanager +def active_channel(channel: SuspendChannel) -> Iterator[None]: + """Make ``channel`` the one this run's UiPath tools resolve against. + + The runtime enters this before it constructs the SDK client, so every task + the client spawns inherits the binding. + """ + token = _ACTIVE_CHANNEL.set(channel) + try: + yield + finally: + _ACTIVE_CHANNEL.reset(token) + + +async def interrupt(value: Any) -> Any: + """Suspend the run on ``value`` and return what resumes it. + + On the suspend pass this never returns: it raises a private carrier the + ``PreToolUse`` hook turns into a deferred call, and the run reports + SUSPENDED with ``value`` as its interrupt payload. On the resume pass the + same tool body runs again and this returns the resolved payload instead. + + Args: + value: What the platform turns into a resume trigger, normally a model + from ``uipath.platform.common``. A list becomes sibling triggers + resolved by whichever fires first. + + Returns: + The payload the fired trigger carried back. + + Raises: + InterruptOutsideRunError: If no UiPath run is wrapping this call. + """ + call = _ACTIVE_CALL.get() + if call is None: + raise InterruptOutsideRunError(_OUTSIDE_RUN_MESSAGE) + if call.channel.is_resolved(call.tool_use_id): + return call.channel.resolved_for(call.tool_use_id) + raise _InterruptRaised(value) + + +def _arguments_key(args: Mapping[str, Any]) -> str: + """A stable key for one call's arguments. + + The hook and the handler see the same arguments and nothing else they share, + so the arguments are what correlates the stashed outcome with the call that + produced it. + """ + try: + return json.dumps(args, sort_keys=True, default=str) + except (TypeError, ValueError): + return repr(sorted(args.items(), key=lambda item: item[0])) + + +def call_key(binding: InterruptToolBinding, args: Mapping[str, Any]) -> str: + """The stash key for one call of one tool.""" + return f"{binding.token}:{_arguments_key(args)}" + + +async def run_tool_body( + channel: SuspendChannel, + binding: InterruptToolBinding, + args: dict[str, Any], + tool_name: str, + tool_use_id: str, +) -> ToolOutcome | None: + """Run one developer tool body on behalf of the ``PreToolUse`` hook. + + Args: + channel: The run's suspend channel. + binding: The tool whose body to run. + args: Arguments the model passed. + tool_name: Model-visible tool name, recorded on the pending suspension. + tool_use_id: The CLI's tool_use id, which becomes the interrupt id. + + Returns: + The body's outcome, or ``None`` when it suspended and the call must be + deferred. + + Raises: + SuspendAlreadyPendingError: If another interrupt is already in flight. + """ + token = _ACTIVE_CALL.set(_CallContext(channel=channel, tool_use_id=tool_use_id)) + try: + with _under_turn(channel): + result = await binding.body(args) + except _InterruptRaised as raised: + await channel.claim(tool_name, raised.value, tool_use_id) + return None + except Exception as error: + return ToolOutcome(error=error, tool_use_id=tool_use_id) + else: + return ToolOutcome(result=result, tool_use_id=tool_use_id) + finally: + _ACTIVE_CALL.reset(token) + + +def _settle_delivered(channel: SuspendChannel, tool_use_id: str | None) -> None: + """Clear the pending record once its resolved value reaches the model.""" + pending = channel.pending + if pending is None or pending.interrupt_id != tool_use_id: + return + if channel.is_resolved(pending.interrupt_id): + channel.complete(pending.interrupt_id) + + +async def _run_unstashed( + channel: SuspendChannel, body: ToolBody, args: dict[str, Any] +) -> dict[str, Any]: + """Run a body the hook did not run, and refuse to suspend from there. + + The handler is past the point where a call can be parked, so a body that + tries to interrupt here is told so as a tool error the model can act on + rather than faulting the job. The known cause is a defer the CLI dropped, + which the runtime also fails the run over. + """ + token = _ACTIVE_CALL.set(_CallContext(channel=channel, tool_use_id=None)) + try: + with _under_turn(channel): + return await body(args) + except _InterruptRaised: + logger.warning( + "A UiPath tool tried to suspend from its handler, so its call was " + "never parked." + ) + return { + "content": [{"type": "text", "text": _NOT_DEFERRED_MESSAGE}], + "is_error": True, + } + finally: + _ACTIVE_CALL.reset(token) + + +def _wrap(tool_def: SdkMcpTool[Any]) -> tuple[SdkMcpTool[Any], InterruptToolBinding]: + """Replace a tool's handler with one that delivers the hook's outcome.""" + binding = InterruptToolBinding(token=uuid4().hex, body=tool_def.handler) + + async def handler(args: dict[str, Any]) -> dict[str, Any]: + channel = _ACTIVE_CHANNEL.get() + if channel is None: + return await binding.body(args) + outcome = channel.take(call_key(binding, args)) + if outcome is None: + return await _run_unstashed(channel, binding.body, args) + _settle_delivered(channel, outcome.tool_use_id) + return outcome.deliver() + + wrapped = SdkMcpTool( + name=tool_def.name, + description=tool_def.description, + input_schema=tool_def.input_schema, + handler=handler, + annotations=tool_def.annotations, + ) + return wrapped, binding + + +_REGISTRY: WeakKeyDictionary[Any, dict[str, InterruptToolBinding]] = WeakKeyDictionary() +"""Server instance to its tools, keyed by bare name, for what this module built. + +Keying on the instance lets :func:`uipath_tool_server` return a plain +``McpSdkServerConfig`` while the runtime can still tell which servers are its +own, and lets the qualified names be derived from the ``mcp_servers`` key the +developer actually used. +""" + + +def uipath_tool_server( + name: str, + tools: list[SdkMcpTool[Any]], + version: str = "1.0.0", +) -> McpSdkServerConfig: + """Build an in-process MCP server whose tools may call :func:`interrupt`. + + The returned config is an ordinary ``McpSdkServerConfig``: register it under + whatever key you like in ``ClaudeAgentOptions.mcp_servers``. Its tools keep + the names, descriptions and schemas you gave them, and outside a UiPath run + they behave like any other SDK tool. + + Args: + name: MCP server name. + tools: Tools built with the SDK's ``@tool`` decorator. + version: Server version string. + + Returns: + A config to place in ``ClaudeAgentOptions.mcp_servers``. + """ + pairs = [_wrap(tool_def) for tool_def in tools] + config = create_sdk_mcp_server( + name=name, version=version, tools=[wrapped for wrapped, _ in pairs] + ) + _REGISTRY[config["instance"]] = { + wrapped.name: binding for wrapped, binding in pairs + } + return config + + +def uipath_tool_index(mcp_servers: Any) -> ToolIndex: + """Map every UiPath tool in a mapping to the body the hook must run. + + Args: + mcp_servers: ``ClaudeAgentOptions.mcp_servers`` as the developer wrote + it. Anything that is not a mapping yields an empty index, which is + how an agent ends up with no UiPath wiring at all. + + Returns: + Model-visible tool name to its binding. + """ + if not isinstance(mcp_servers, Mapping): + return {} + index: ToolIndex = {} + for server_name, config in mcp_servers.items(): + instance = config.get("instance") if isinstance(config, Mapping) else None + if instance is None: + continue + for tool_name, binding in _REGISTRY.get(instance, {}).items(): + index[f"mcp__{server_name}__{tool_name}"] = binding + return index diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/mcp.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/mcp.py new file mode 100644 index 00000000..b432d91e --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/mcp.py @@ -0,0 +1,176 @@ +"""Helpers for pointing a Claude agent at MCP servers. + +MCP support comes from the Claude Agent SDK itself: anything placed in +``ClaudeAgentOptions.mcp_servers`` is started and connected by the Claude Code +CLI. This module only removes the boilerplate around the two remote transports +(``http`` and ``sse``) and around resolving a UiPath-hosted MCP server to the +URL and bearer token it expects. Stdio and in-process SDK servers need no +helper: pass their config dictionaries straight through. +""" + +from __future__ import annotations + +import os +from typing import Literal + +from claude_agent_sdk.types import ( + McpHttpServerConfig, + McpServerConfig, + McpSSEServerConfig, +) + +from .interrupts import UIPATH_MCP_SERVER_NAME + +McpTransport = Literal["http", "sse"] +RemoteMcpServerConfig = McpHttpServerConfig | McpSSEServerConfig + +_ACCESS_TOKEN_ENV = "UIPATH_ACCESS_TOKEN" + +__all__ = [ + "UIPATH_MCP_SERVER_NAME", + "McpTransport", + "RemoteMcpServerConfig", + "validate_mcp_servers", + "remote_mcp_server", + "uipath_mcp_server", +] + + +def remote_mcp_server( + url: str, + *, + access_token: str | None = None, + transport: McpTransport = "http", + headers: dict[str, str] | None = None, +) -> RemoteMcpServerConfig: + """Build the config for an MCP server reached over HTTP or SSE. + + Args: + url: Endpoint of the MCP server. UiPath-hosted servers expose a + streamable HTTP endpoint, so ``transport="http"`` is the default. + access_token: Bearer token to authenticate with. Defaults to the + ``UIPATH_ACCESS_TOKEN`` environment variable, which the UiPath + runtime populates. No ``Authorization`` header is sent when + neither is available, which is what a public MCP server wants. + transport: ``"http"`` for streamable HTTP, ``"sse"`` for server-sent + events. + headers: Extra headers merged into the request. An ``Authorization`` + entry here wins over ``access_token``. + + Returns: + A config to place under a key of ``ClaudeAgentOptions.mcp_servers``. + The key must not be ``uipath``: see :func:`validate_mcp_servers`. + + Raises: + ValueError: If ``url`` is empty or ``transport`` is not a remote one. + """ + if not url: + raise ValueError("A remote MCP server needs a non-empty url.") + + resolved = dict(headers or {}) + token = ( + access_token if access_token is not None else os.environ.get(_ACCESS_TOKEN_ENV) + ) + if token and "Authorization" not in resolved: + resolved["Authorization"] = f"Bearer {token}" + + if transport == "http": + http_config: McpHttpServerConfig = {"type": "http", "url": url} + if resolved: + http_config["headers"] = resolved + return http_config + + if transport == "sse": + sse_config: McpSSEServerConfig = {"type": "sse", "url": url} + if resolved: + sse_config["headers"] = resolved + return sse_config + + raise ValueError( + f"Unsupported MCP transport {transport!r}. Use 'http' or 'sse', or pass " + "a stdio or SDK server config directly." + ) + + +def uipath_mcp_server( + slug: str | None = None, + *, + name: str | None = None, + folder_path: str | None = None, + access_token: str | None = None, + transport: McpTransport = "http", + headers: dict[str, str] | None = None, +) -> RemoteMcpServerConfig: + """Resolve a UiPath-hosted MCP server and build its config. + + The server is looked up through the UiPath SDK, which returns the endpoint + the tenant serves it on. The lookup is a network call made when this + function runs, so calling it while building a module-level agent means + ``uipath init`` and ``uipath run`` both need an authenticated tenant that + already hosts the server. Pass the endpoint to :func:`remote_mcp_server` + instead when that is not wanted. + + Args: + slug: Legacy slug of the server. + name: Display name of the server. Give either this or ``slug``. + folder_path: Folder the server lives in. Defaults to the folder of the + current execution context. + access_token: Bearer token to authenticate with. Defaults to the + credential the UiPath SDK resolved. + transport: ``"http"`` for streamable HTTP, ``"sse"`` for server-sent + events. + headers: Extra headers merged into the request. + + Returns: + A config to place under a key of ``ClaudeAgentOptions.mcp_servers``. + The key must not be ``uipath``: see :func:`validate_mcp_servers`. + + Raises: + ValueError: If the server has no endpoint configured. + """ + from uipath.platform import UiPath + + sdk = UiPath() + server = sdk.mcp.retrieve(slug, name=name, folder_path=folder_path) + if not server.mcp_url: + raise ValueError( + f"UiPath MCP server {name or slug!r} has no URL configured, so the " + "agent cannot connect to it." + ) + + return remote_mcp_server( + server.mcp_url, + access_token=access_token or sdk._config.secret, + transport=transport, + headers=headers, + ) + + +def validate_mcp_servers( + servers: dict[str, McpServerConfig], +) -> dict[str, McpServerConfig]: + """Validate an ``mcp_servers`` mapping while the agent is being defined. + + The runtime registers its own in-process server under the ``uipath`` key, + which is where the interrupt tools that suspend a run live. A developer + server under that key would hide them, so the runtime refuses to start. + Passing the mapping through here surfaces the collision when the module is + imported rather than on the first run. + + Args: + servers: Mapping of server name to config, as + ``ClaudeAgentOptions.mcp_servers`` accepts it. + + Returns: + The same mapping, copied. + + Raises: + ValueError: If the mapping uses the reserved ``uipath`` key. + """ + if UIPATH_MCP_SERVER_NAME in servers: + raise ValueError( + f"MCP server name {UIPATH_MCP_SERVER_NAME!r} is reserved for the " + "UiPath interrupt tools. Rename that server so its tools stay " + "reachable." + ) + return dict(servers) diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/middlewares.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/middlewares.py new file mode 100644 index 00000000..6874cbc1 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/middlewares.py @@ -0,0 +1,8 @@ +from uipath._cli.middlewares import Middlewares + +from ._cli.cli_new import claude_new_middleware + + +def register_middleware(): + """This function will be called by the entry point system when uipath-claude-sdk is installed""" + Middlewares.register("new", claude_new_middleware) diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/models.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/models.py new file mode 100644 index 00000000..e0bd2a16 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/models.py @@ -0,0 +1,33 @@ +"""Routing descriptor for models served by the UiPath LLM Gateway. + +A :class:`UiPathModel` declares which tenant model an agent should use. +Attaching one to a :class:`~uipath_claude_sdk.agent.UiPathClaudeAgent` is the +explicit opt-in that makes the runtime start its local gateway shim. + +How to route to that model is not declared here. The tenant's discovery response +reports the vendor and the wire format for every model it serves, and the shim +reads the route from there, which is how a model name that works in +``uipath-langchain`` works here too. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ["UiPathModel"] + + +@dataclass +class UiPathModel: + """A routing descriptor for a UiPath-hosted model. + + This is not a client. It holds no connection, opens no socket and is not + callable. It only records which model to use, and the runtime reads it when + it builds the environment for the Claude SDK subprocess. + + Args: + model: Model id as listed by the tenant's discovery endpoint, for + example ``"claude-sonnet-4-5"``. + """ + + model: str diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/__init__.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/__init__.py new file mode 100644 index 00000000..12a0eb98 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/__init__.py @@ -0,0 +1,35 @@ +"""UiPath Claude SDK Runtime.""" + +from uipath.runtime import ( + UiPathRuntimeContext, + UiPathRuntimeFactoryProtocol, + UiPathRuntimeFactoryRegistry, +) + +from .conversational_runtime import UiPathClaudeSDKConversationalRuntime +from .factory import UiPathClaudeSDKRuntimeFactory +from .runtime import UiPathClaudeSDKRuntime +from .schema import get_agent_graph, get_entrypoints_schema + + +def register_runtime_factory() -> None: + """Register the Claude SDK factory. Called automatically via entry point.""" + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return UiPathClaudeSDKRuntimeFactory( + context=context if context else UiPathRuntimeContext(), + ) + + UiPathRuntimeFactoryRegistry.register("claude", create_factory, "claude.json") + + +__all__ = [ + "register_runtime_factory", + "get_entrypoints_schema", + "get_agent_graph", + "UiPathClaudeSDKRuntimeFactory", + "UiPathClaudeSDKRuntime", + "UiPathClaudeSDKConversationalRuntime", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/_telemetry.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/_telemetry.py new file mode 100644 index 00000000..92e5aac6 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/_telemetry.py @@ -0,0 +1,341 @@ +"""The telemetry this package owns on top of the Claude Agent SDK instrumentor. + +``openinference-instrumentation-claude-agent-sdk`` wraps the SDK's own entry +points and emits the AGENT span per turn and the TOOL span per tool call. Three +things are left over, and they live here. + +LLM spans and token counts. The instrumentor emits none, and its README's advice +to add ``openinference-instrumentation-anthropic`` does not apply: the bundled +Claude Code CLI issues every model HTTP call from its own subprocess, so +patching this process's ``anthropic`` client would instrument nothing. The +gateway shim is the only place that observes those calls, so +:class:`GatewayCallTelemetry` turns each one it served into an LLM span. + +Cost. The instrumentor copies ``ResultMessage.total_cost_usd`` onto the AGENT +span as ``llm.cost.total``. The CLI computes that number client-side from +Anthropic's public catalogue, which says nothing about what a gateway-routed +model costs. + +Tool identity. The instrumentor writes the ``tool_use_id`` of one invocation, +a value minted fresh by the model on every call, to ``tool.id``. UiPath reads +that attribute as the tool's stable identifier, taken from its resource +definition, and prefers it over ``tool.name`` when scoring a tool-call +evaluation, so a per-invocation value there leaves every criterion unmatchable. + +The last two are attributes whose meaning does not survive the trip to UiPath, +and :class:`AttributeStripper` removes both on the way out. +""" + +from __future__ import annotations + +import logging +import time +from collections import deque +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from openinference.semconv.trace import ( + OpenInferenceSpanKindValues, + SpanAttributes, +) +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor +from opentelemetry.sdk.trace import Span as SdkSpan +from opentelemetry.trace import Span, Status, StatusCode, Tracer +from uipath.core.serialization import serialize_json +from uipath.platform.chat.llm_trace_context import build_trace_context_headers + +from .gateway.records import GatewayCallRecord + +logger = logging.getLogger(__name__) + +TRACER_NAME = "uipath-claude-sdk" + +SPAN_KIND = SpanAttributes.OPENINFERENCE_SPAN_KIND +LLM_SPAN_KIND = OpenInferenceSpanKindValues.LLM.value +LLM_MODEL_NAME = SpanAttributes.LLM_MODEL_NAME +PROMPT_TOKENS = SpanAttributes.LLM_TOKEN_COUNT_PROMPT +COMPLETION_TOKENS = SpanAttributes.LLM_TOKEN_COUNT_COMPLETION +TOTAL_TOKENS = SpanAttributes.LLM_TOKEN_COUNT_TOTAL +LLM_COST_TOTAL = SpanAttributes.LLM_COST_TOTAL +TOOL_ID = SpanAttributes.TOOL_ID +LLM_PROVIDER = SpanAttributes.LLM_PROVIDER +LLM_SYSTEM = SpanAttributes.LLM_SYSTEM +LLM_INPUT_MESSAGES = SpanAttributes.LLM_INPUT_MESSAGES +LLM_OUTPUT_MESSAGES = SpanAttributes.LLM_OUTPUT_MESSAGES +LLM_INVOCATION_PARAMETERS = SpanAttributes.LLM_INVOCATION_PARAMETERS +INPUT_VALUE = SpanAttributes.INPUT_VALUE +INPUT_MIME_TYPE = SpanAttributes.INPUT_MIME_TYPE +OUTPUT_VALUE = SpanAttributes.OUTPUT_VALUE +OUTPUT_MIME_TYPE = SpanAttributes.OUTPUT_MIME_TYPE + +LLM_SPAN_NAME = "AnthropicMessages" +_TRACE_SOURCE = "source=claude-sdk" +_ANTHROPIC = "anthropic" +_JSON = "application/json" +_INVOCATION_KEYS = ("max_tokens", "temperature", "top_p", "top_k", "stream") + +STRIPPED_ATTRIBUTES = frozenset({LLM_COST_TOTAL, TOOL_ID}) +"""Attributes the instrumentor sets that must not reach UiPath.""" + +_MAX_BUFFERED_CALLS = 256 +_PROMPT_TOKEN_KEYS = ( + "input_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", +) + + +@dataclass(frozen=True) +class _BufferedCall: + """One upstream call and the moment the shim finished reporting it.""" + + record: GatewayCallRecord + arrived_ns: int + + +class GatewayCallTelemetry: + """Emits the LLM span for every upstream call the gateway shim served. + + Wired into ``GatewayShim(on_call=...)``. Records are buffered as they + arrive, on whichever task served the request, and turned into spans when the + run flushes them. The buffer belongs to a single run and must not be shared + with another run executing in the same process. + + Args: + tracer: Tracer to build spans with. Defaults to the global provider's. + """ + + def __init__(self, tracer: Tracer | None = None) -> None: + self._tracer = tracer if tracer is not None else trace.get_tracer(TRACER_NAME) + self._gateway_calls: deque[_BufferedCall] = deque(maxlen=_MAX_BUFFERED_CALLS) + self._parent: Context | None = None + + def adopt_parent(self, parent: Context) -> None: + """Nest these spans under the turn they belong to. + + The shim serves the CLI's calls on its own task and the spans are built + after the turn has finished, so neither moment has the instrumentor's + agent span in context. The runtime captures it while iterating the + response, where it is current, and hands it over. + """ + self._parent = parent + + def trace_context_headers(self) -> dict[str, str]: + """Headers that let the LLM Gateway record the model span itself. + + Every UiPath agent framework hands the gateway the caller's trace and + span through these, and the gateway answers with a model span of its + own, so agents built on different frameworks produce the same trace. + Returns nothing when the platform has the feature turned off, which is + the signal to fall back to recording the span here. + """ + if self._parent is None: + return {} + token = context_api.attach(self._parent) + try: + return build_trace_context_headers(extra_baggage=[_TRACE_SOURCE]) + except Exception: + logger.debug("could not build trace context headers", exc_info=True) + return {} + finally: + context_api.detach(token) + + def on_gateway_call(self, record: GatewayCallRecord) -> None: + """Buffer one upstream call.""" + self._gateway_calls.append(_BufferedCall(record, time.time_ns())) + + def flush_gateway_calls(self) -> None: + """Emit a span per buffered call, including background and subagent traffic.""" + buffered = list(self._gateway_calls) + self._gateway_calls.clear() + for call in buffered: + try: + self._emit_gateway_span(call) + except Exception: + logger.exception("Error emitting a span for a gateway call") + + def _emit_gateway_span(self, call: _BufferedCall) -> None: + record = call.record + if record.traced_upstream: + return + start_ns = call.arrived_ns - int(record.duration_ms * 1_000_000) + span = self._tracer.start_span( + LLM_SPAN_NAME, context=self._parent, start_time=start_ns + ) + span.set_attribute(SPAN_KIND, LLM_SPAN_KIND) + span.set_attribute(LLM_MODEL_NAME, record.resolved_model) + span.set_attribute(LLM_PROVIDER, record.vendor_type) + span.set_attribute(LLM_SYSTEM, _ANTHROPIC) + _set_token_counts(span, *_record_tokens(record)) + _set_prompt(span, record.request_body) + _set_completion(span, record.output_content, record.stop_reason) + if record.error is not None: + span.set_status(Status(StatusCode.ERROR, record.error)) + else: + span.set_status(Status(StatusCode.OK)) + span.end(end_time=call.arrived_ns) + + +def _set_prompt(span: Span, request: dict[str, Any] | None) -> None: + """Record what the model was asked, as openinference input messages.""" + if not request: + return + messages: list[dict[str, Any]] = [] + system = request.get("system") + if system: + messages.append({"role": "system", "content": _flatten(system)}) + for message in request.get("messages", []): + if isinstance(message, dict): + messages.append( + { + "role": str(message.get("role", "user")), + "content": _flatten(message.get("content")), + } + ) + for index, message in enumerate(messages): + prefix = f"{LLM_INPUT_MESSAGES}.{index}.message" + span.set_attribute(f"{prefix}.role", message["role"]) + span.set_attribute(f"{prefix}.content", message["content"]) + span.set_attribute(INPUT_VALUE, serialize_json(messages)) + span.set_attribute(INPUT_MIME_TYPE, _JSON) + params = {k: request[k] for k in _INVOCATION_KEYS if k in request} + if params: + span.set_attribute(LLM_INVOCATION_PARAMETERS, serialize_json(params)) + + +def _set_completion( + span: Span, content: list[dict[str, Any]], stop_reason: str | None +) -> None: + """Record what the model answered, tool calls included.""" + if not content: + return + prefix = f"{LLM_OUTPUT_MESSAGES}.0.message" + span.set_attribute(f"{prefix}.role", "assistant") + text = " ".join( + str(b.get("text", "")) for b in content if b.get("type") == "text" + ).strip() + if text: + span.set_attribute(f"{prefix}.content", text) + thinking = " ".join( + str(b.get("thinking", "")) for b in content if b.get("type") == "thinking" + ).strip() + if thinking: + span.set_attribute(f"{prefix}.reasoning", thinking) + for index, block in enumerate(b for b in content if b.get("type") == "tool_use"): + call_prefix = f"{prefix}.tool_calls.{index}.tool_call" + span.set_attribute(f"{call_prefix}.function.name", str(block.get("name", ""))) + span.set_attribute( + f"{call_prefix}.function.arguments", + str(block.get("partial_json") or serialize_json(block.get("input") or {})), + ) + if stop_reason: + span.set_attribute("llm.stop_reason", stop_reason) + span.set_attribute(OUTPUT_VALUE, serialize_json(content)) + span.set_attribute(OUTPUT_MIME_TYPE, _JSON) + + +def _flatten(content: Any) -> str: + """Anthropic content, which may be a string or a block list, as text.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + return serialize_json(content) + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + match block.get("type"): + case "text": + parts.append(str(block.get("text", ""))) + case "tool_result": + parts.append(f"[tool_result] {_flatten(block.get('content'))}") + case "tool_use": + parts.append(f"[tool_use {block.get('name')}] {block.get('input')}") + case other: + parts.append(f"[{other}]") + return "\n".join(p for p in parts if p) + + +class AttributeStripper(SpanProcessor): + """Removes every :data:`STRIPPED_ATTRIBUTES` entry before a span is exported. + + A span's attributes are sealed by the time ``on_end`` runs, so the filtered + copy is swapped onto the ``ReadableSpan`` the processors share rather than + written through it. Processors registered after this one, which is where the + UiPath exporters are added, therefore see the stripped span. + """ + + def on_start(self, span: SdkSpan, parent_context: Context | None = None) -> None: + """Nothing to do: the attributes are only complete once the span ends.""" + + def on_end(self, span: ReadableSpan) -> None: + """Drop the stripped attributes, leaving the rest of the span untouched.""" + attributes = span.attributes + if not attributes or STRIPPED_ATTRIBUTES.isdisjoint(attributes): + return + try: + span._attributes = { + key: value + for key, value in attributes.items() + if key not in STRIPPED_ATTRIBUTES + } + except Exception: + logger.debug("Failed to strip attributes from span '%s'", span.name) + + def shutdown(self) -> None: + """Nothing to release.""" + + def force_flush(self, timeout_millis: int = 30000) -> bool: + """Nothing is held back, so there is nothing to flush.""" + return True + + +def _usage_tokens(usage: Mapping[str, Any] | None) -> tuple[int | None, int | None]: + """Prompt and completion counts from an Anthropic usage mapping. + + Cache reads and cache writes are billed input, so they join the prompt + count rather than disappearing from the total. + """ + if not isinstance(usage, Mapping): + return None, None + prompt: int | None = None + for key in _PROMPT_TOKEN_KEYS: + value = usage.get(key) + if isinstance(value, int): + prompt = value if prompt is None else prompt + value + completion = usage.get("output_tokens") + return prompt, completion if isinstance(completion, int) else None + + +def _record_tokens(record: GatewayCallRecord) -> tuple[int | None, int | None]: + """Prompt and completion counts from a gateway call record.""" + return _usage_tokens( + { + "input_tokens": record.input_tokens, + "cache_read_input_tokens": record.cache_read_input_tokens, + "cache_creation_input_tokens": record.cache_creation_input_tokens, + "output_tokens": record.output_tokens, + } + ) + + +def _set_token_counts(span: Span, prompt: int | None, completion: int | None) -> None: + if prompt is not None: + span.set_attribute(PROMPT_TOKENS, prompt) + if completion is not None: + span.set_attribute(COMPLETION_TOKENS, completion) + if prompt is not None and completion is not None: + span.set_attribute(TOTAL_TOKENS, prompt + completion) + + +__all__ = [ + "LLM_SPAN_NAME", + "STRIPPED_ATTRIBUTES", + "TRACER_NAME", + "AttributeStripper", + "GatewayCallTelemetry", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/config.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/config.py new file mode 100644 index 00000000..b0cac3e3 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/config.py @@ -0,0 +1,53 @@ +"""Configuration loader for the Claude SDK integration (claude.json).""" + +from __future__ import annotations + +import json +import os + + +class ClaudeConfig: + """Loader for claude.json configuration. + + Format: ``{"agents": {"agent": "main.py:agent"}}`` + """ + + def __init__(self, config_path: str = "claude.json"): + self.config_path = config_path + self._agents: dict[str, str] | None = None + + @property + def exists(self) -> bool: + return os.path.exists(self.config_path) + + @property + def agents(self) -> dict[str, str]: + if self._agents is None: + self._agents = self._load_agents() + return self._agents + + @property + def entrypoint(self) -> list[str]: + return list(self.agents.keys()) + + def _load_agents(self) -> dict[str, str]: + if not self.exists: + raise FileNotFoundError( + f"Claude configuration file not found at {self.config_path}" + ) + + try: + with open(self.config_path, "r") as f: + config = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in '{self.config_path}': {e}") from e + + agents = config.get("agents") + if not isinstance(agents, dict) or not all( + isinstance(v, str) for v in agents.values() + ): + raise ValueError( + "Missing or invalid 'agents' key in claude.json configuration file. " + 'Expected {"agents": {"name": "file.py:variable"}}.' + ) + return agents diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/conversational_runtime.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/conversational_runtime.py new file mode 100644 index 00000000..75a88532 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/conversational_runtime.py @@ -0,0 +1,330 @@ +"""Conversational runtime for Claude Agent SDK agents. + +Runs one conversation exchange per invocation: streams the assistant response +as UiPath conversation message events, then suspends on nothing, which is how +a chat host reads "the turn is over, waiting for the user". The Claude SDK +session id is persisted so the SDK re-attaches to the same conversation +(``ClaudeAgentOptions(resume=session_id)``), and a stable per-conversation +workspace directory preserves files across exchanges. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncGenerator +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, + UserMessage, +) +from uipath.core.chat.content import ( + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, +) +from uipath.core.chat.message import ( + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, + UiPathConversationMessageStartEvent, +) +from uipath.runtime import ( + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +from ..agent import ClaudeAgent +from ..interrupts import active_channel +from .runtime import RESUME_PROMPT, RunContext, UiPathClaudeSDKRuntime +from .schema import get_agent_graph, get_entrypoints_schema +from .session_paths import ClaudeSessionPaths +from .session_store import ClaudeSessionStore + +logger = logging.getLogger(__name__) + + +class UiPathClaudeSDKConversationalRuntime(UiPathClaudeSDKRuntime): + """Conversational (multi-turn) runtime for Claude Agent SDK agents. + + Every exchange ends suspended, so the job survives to serve the next one. + An exchange the model parked in suspends on that interrupt and leaves the + exchange open until it resolves; a finished exchange suspends with no + interrupt at all, which is what closes it. + + Args: + agent: The loaded ClaudeAgent definition. + session_store: Persists the Claude session id between exchanges, plus + any interrupt the conversation is suspended on. + session_paths: Durable Claude config and working directories. The + workspace is keyed on the conversation, so files created in one + exchange are still there in the next. + runtime_id: Unique identifier for this runtime instance. + entrypoint: Agent entrypoint name (for schema reporting). + """ + + def __init__( + self, + agent: ClaudeAgent, + session_store: ClaudeSessionStore, + session_paths: ClaudeSessionPaths, + runtime_id: str | None = None, + entrypoint: str | None = None, + ): + super().__init__( + agent=agent, + session_store=session_store, + session_paths=session_paths, + runtime_id=runtime_id, + entrypoint=entrypoint, + ) + + async def _session_id_for_run(self, resuming: bool) -> str | None: + """Re-attach to the conversation's session on every exchange after the first.""" + return await self._session_store.get_session_id() + + def _user_message_for_run( + self, input: dict[str, Any] | None, context: RunContext + ) -> str: + """The message that starts the exchange. + + Every exchange after the first arrives as a resume, so the base + behaviour of replacing the prompt with a fixed continuation would + discard what the user just said. A resume resolving a parked interrupt + is the exception: there the payload reaches the model as that call's + tool result, and no new instruction belongs in the turn. + """ + if context.pending is not None: + return RESUME_PROMPT + return self._get_user_message(input or {}) or RESUME_PROMPT + + # --- Input mapping ------------------------------------------------------ + + def _get_user_message(self, input: dict[str, Any]) -> str: + """Extract the user prompt from UiPath conversation format input. + + On resume, the input is the resume map {interrupt_id: resume_data} + produced by UiPathResumableRuntime. The resume data carries the next + user message in the same conversation format. + """ + message = self._extract_messages(input) + if message: + return message + + for value in input.values(): + if isinstance(value, dict): + message = self._extract_messages(value) + if message: + return message + elif isinstance(value, str) and value: + return value + + return "" + + @classmethod + def _extract_messages(cls, input: dict[str, Any]) -> str: + """Extract text from the last user message in UiPath conversation format. + + Expects: [{"role": "user", "contentParts": [{"data": {"inline": "..."}}]}] + """ + messages = input.get("messages") + if not isinstance(messages, list) or not messages: + return "" + + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + role = msg.get("role", "") + if role and role != "user": + continue + text = cls._extract_text_from_content_parts(msg) + if text: + return text + + if isinstance(messages[-1], dict): + return cls._extract_text_from_content_parts(messages[-1]) + + return "" + + @staticmethod + def _extract_text_from_content_parts(msg: dict[str, Any]) -> str: + content_parts = msg.get("contentParts") + if not isinstance(content_parts, list): + return "" + + texts: list[str] = [] + for cp in content_parts: + if not isinstance(cp, dict): + continue + data = cp.get("data") + if isinstance(data, dict) and "inline" in data: + inline = data["inline"] + if isinstance(inline, str) and inline: + texts.append(inline) + return "".join(texts) + + # --- Conversation event helpers ------------------------------------------ + + @staticmethod + def _get_timestamp() -> str: + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" + + def _message_start_events( + self, message_id: str, content_part_id: str + ) -> UiPathRuntimeMessageEvent: + return UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp=self._get_timestamp(), + ), + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + start=UiPathConversationContentPartStartEvent( + mime_type="text/plain", + ), + ), + ), + ) + + @staticmethod + def _message_chunk_event( + message_id: str, content_part_id: str, text: str + ) -> UiPathRuntimeMessageEvent: + return UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + chunk=UiPathConversationContentPartChunkEvent( + data=text, + ), + ), + ), + ) + + @staticmethod + def _message_end_event( + message_id: str, content_part_id: str + ) -> UiPathRuntimeMessageEvent: + return UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + end=UiPathConversationMessageEndEvent(), + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id, + end=UiPathConversationContentPartEndEvent(), + ), + ), + ) + + # --- Runtime protocol ----------------------------------------------------- + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Run one conversation exchange and suspend for the next user message. + + Yields: + UiPathRuntimeMessageEvent for assistant text (message start, + content-part chunks, message end), UiPathRuntimeStateEvent for + tool activity, then a SUSPENDED UiPathRuntimeResult: carrying the + interrupt the model parked on, or carrying nothing when the turn + simply finished. + """ + try: + context = await self._open_run(input, options) + user_message = self._user_message_for_run(input, context) + + result_message: ResultMessage | None = None + try: + with active_channel(context.channel): + async with ClaudeSDKClient(options=context.sdk_options) as client: + await client.query(user_message) + async for message in self._receive_run_messages(client): + self._adopt_span_parent(context.channel) + if isinstance(message, AssistantMessage): + for event in self._map_conversational_assistant( + message + ): + yield event + elif isinstance(message, UserMessage): + for state_event in self._map_tool_results(message): + yield state_event + elif isinstance(message, ResultMessage): + await self._on_result_message(message, context) + result_message = message + elif isinstance(message, SystemMessage): + mapped = self._map_system(message) + if mapped is not None: + yield mapped + finally: + await self._stop_llm_gateway() + self._flush_gateway_spans() + + if result_message is not None and result_message.deferred_tool_use: + yield await self._suspended_result(context) + return + + self._refuse_ignored_deferral(context) + await self._clear_resolved_pending(context) + await self._capture_transcript(await self._session_store.get_session_id()) + yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUSPENDED) + except Exception as e: + raise self.create_runtime_error(e) from e + + def _map_conversational_assistant( + self, message: AssistantMessage + ) -> list[UiPathRuntimeEvent]: + """Map an assistant message to conversation events (text) and state events (tools/thinking).""" + events: list[UiPathRuntimeEvent] = [] + + text_blocks = [b for b in message.content if isinstance(b, TextBlock)] + if text_blocks: + message_id = str(uuid4()) + content_part_id = f"chunk-{message_id}-0" + events.append(self._message_start_events(message_id, content_part_id)) + for block in text_blocks: + if block.text: + events.append( + self._message_chunk_event( + message_id, content_part_id, block.text + ) + ) + events.append(self._message_end_event(message_id, content_part_id)) + + non_text = AssistantMessage( + content=[b for b in message.content if not isinstance(b, TextBlock)], + model=message.model, + ) + events.extend(self._map_assistant(non_text)) + return events + + async def get_schema(self) -> UiPathRuntimeSchema: + """Get the conversational schema (UiPath conversation message format).""" + entrypoints_schema = get_entrypoints_schema(self.agent, conversational=True) + + return UiPathRuntimeSchema( + filePath=self.entrypoint or "", + uniqueId=str(uuid4()), + type="agent", + input=entrypoints_schema.get("input", {}), + output=entrypoints_schema.get("output", {}), + graph=get_agent_graph(self.agent), + ) diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/errors.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/errors.py new file mode 100644 index 00000000..ee657503 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/errors.py @@ -0,0 +1,59 @@ +"""Error handling for the Claude SDK runtime.""" + +from enum import Enum + +from uipath.runtime.errors import ( + UiPathBaseRuntimeError, + UiPathErrorCategory, + UiPathErrorCode, +) + + +class UiPathClaudeSDKErrorCode(Enum): + """Error codes specific to the Claude SDK runtime.""" + + AGENT_EXECUTION_ERROR = "AGENT_EXECUTION_ERROR" + AGENT_TIMEOUT = "AGENT_TIMEOUT" + STREAM_ERROR = "STREAM_ERROR" + + CONFIG_MISSING = "CONFIG_MISSING" + CONFIG_INVALID = "CONFIG_INVALID" + + AGENT_NOT_FOUND = "AGENT_NOT_FOUND" + AGENT_TYPE_ERROR = "AGENT_TYPE_ERROR" + AGENT_VALUE_ERROR = "AGENT_VALUE_ERROR" + AGENT_LOAD_FAILURE = "AGENT_LOAD_FAILURE" + AGENT_IMPORT_ERROR = "AGENT_IMPORT_ERROR" + + INPUT_VALIDATION_ERROR = "INPUT_VALIDATION_ERROR" + OUTPUT_VALIDATION_ERROR = "OUTPUT_VALIDATION_ERROR" + + GATEWAY_PROXY_ERROR = "GATEWAY_PROXY_ERROR" + + SESSION_NOT_FOUND = "SESSION_NOT_FOUND" + SESSION_MISMATCH = "SESSION_MISMATCH" + ENTRYPOINT_MISMATCH = "ENTRYPOINT_MISMATCH" + INTERRUPT_STATE_ERROR = "INTERRUPT_STATE_ERROR" + SUSPEND_IGNORED = "SUSPEND_IGNORED" + + +class UiPathClaudeSDKRuntimeError(UiPathBaseRuntimeError): + """Custom exception for Claude SDK runtime errors with structured error information.""" + + def __init__( + self, + code: UiPathClaudeSDKErrorCode | UiPathErrorCode, + title: str, + detail: str, + category: UiPathErrorCategory = UiPathErrorCategory.UNKNOWN, + status: int | None = None, + ): + super().__init__( + code.value, title, detail, category, status, prefix="ClaudeSDK" + ) + + +__all__ = [ + "UiPathClaudeSDKErrorCode", + "UiPathClaudeSDKRuntimeError", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/factory.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/factory.py new file mode 100644 index 00000000..f8d992f3 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/factory.py @@ -0,0 +1,444 @@ +"""Factory for creating Claude SDK runtimes from claude.json configuration.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any + +from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import Tracer +from uipath.core.feature_flags import FeatureFlags +from uipath.core.tracing import UiPathTraceManager +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeFactorySettings, + UiPathRuntimeProtocol, + UiPathRuntimeStorageProtocol, +) +from uipath.runtime.errors import UiPathErrorCategory + +from ..agent import ClaudeAgent +from ._telemetry import TRACER_NAME, AttributeStripper, GatewayCallTelemetry +from .config import ClaudeConfig +from .conversational_runtime import UiPathClaudeSDKConversationalRuntime +from .errors import ( + UiPathClaudeSDKErrorCode, + UiPathClaudeSDKRuntimeError, +) +from .loader import ClaudeAgentLoader +from .runtime import UiPathClaudeSDKRuntime +from .session_paths import ClaudeSessionPaths +from .session_store import ClaudeSessionStore +from .storage import SqliteResumableStorage + +logger = logging.getLogger(__name__) + +_DEFAULT_RUNTIME_DIR = "__uipath" +_DEFAULT_STATE_FILE = "state.db" +_STATE_FILE_SIDECARS = ("-wal", "-shm") + + +TRACE_CONTEXT_FLAG = "EnableTraceContextHeaders" + + +def _let_the_gateway_record_model_spans() -> None: + """Opt this package into gateway-recorded model spans. + + The flag is read by ``build_trace_context_headers``, and nothing populates + it for a coded agent: the low-code runtime fetches its own flags, and the + shared store otherwise answers False. Configuring it here only supplies a + default, so a deployment that sets ``UIPATH_FEATURE_EnableTraceContextHeaders`` + still decides. + """ + if FeatureFlags.get_flag(TRACE_CONTEXT_FLAG) is not None: + return + FeatureFlags.configure_flags({TRACE_CONTEXT_FLAG: True}) + + +class UiPathClaudeSDKRuntimeFactory: + """Factory for creating Claude SDK runtimes from claude.json configuration.""" + + def __init__( + self, + context: UiPathRuntimeContext, + ): + """ + Initialize the factory. + + Args: + context: UiPathRuntimeContext to use for runtime creation + """ + self.context = context + self._config: ClaudeConfig | None = None + + self._agent_cache: dict[str, ClaudeAgent] = {} + self._agent_loaders: dict[str, ClaudeAgentLoader] = {} + self._agent_lock = asyncio.Lock() + + self._storage_lock = asyncio.Lock() + self._storage: SqliteResumableStorage | None = None + + _let_the_gateway_record_model_spans() + self._tracer = self._setup_instrumentation(self.context.trace_manager) + + @staticmethod + def _setup_instrumentation(trace_manager: UiPathTraceManager | None) -> Tracer: + """Install the Claude Agent SDK instrumentor and the attribute stripper. + + The instrumentor emits the agent and tool spans but no LLM spans, since + the bundled CLI makes the model's HTTP calls in its own process. The + tracer returned here is the one the gateway shim's LLM spans are built + with, and it is the only place a token count comes from. + + The stripper goes through the trace manager rather than onto the + provider, so that it sits alongside the exporters and runs before the + ones the CLI registers once the job is known. + """ + provider = ( + trace_manager.tracer_provider + if trace_manager is not None + else trace.get_tracer_provider() + ) + ClaudeAgentSDKInstrumentor().instrument(tracer_provider=provider) + stripper = AttributeStripper() + if trace_manager is not None: + trace_manager.add_span_processor(stripper) + elif isinstance(provider, TracerProvider): + provider.add_span_processor(stripper) + return provider.get_tracer(TRACER_NAME) + + def _should_reset_state_file(self) -> bool: + """Whether a run starts from an empty state database. + + A resume reads its session id and its pending suspend record out of the + state database, so the reset must be limited to a fresh local run: a + resumed run either carries the resume flag or runs under a job id. + """ + return ( + not self.context.resume + and self.context.job_id is None + and not self.context.keep_state_file + ) + + @staticmethod + def _reset_state_file(path: str) -> None: + """Delete the state database along with its write-ahead sidecars.""" + for candidate in (path, *(path + suffix for suffix in _STATE_FILE_SIDECARS)): + if os.path.exists(candidate): + os.remove(candidate) + + def _get_storage_path(self) -> str: + """Get the storage path for agent state.""" + if self.context.state_file_path is not None: + return self.context.state_file_path + + if self.context.runtime_dir and self.context.state_file: + path = os.path.join(self.context.runtime_dir, self.context.state_file) + if self._should_reset_state_file(): + self._reset_state_file(path) + os.makedirs(self.context.runtime_dir, exist_ok=True) + return path + + default_path = os.path.join(_DEFAULT_RUNTIME_DIR, _DEFAULT_STATE_FILE) + os.makedirs(os.path.dirname(default_path), exist_ok=True) + return default_path + + async def _get_storage(self) -> SqliteResumableStorage: + """Get or create the shared storage instance.""" + if self._storage is not None: + return self._storage + + async with self._storage_lock: + if self._storage is not None: + return self._storage + + self._storage = SqliteResumableStorage(self._get_storage_path()) + return self._storage + + def _load_config(self) -> ClaudeConfig: + """Load claude.json configuration.""" + if self._config is None: + self._config = ClaudeConfig() + return self._config + + async def _load_agent(self, entrypoint: str) -> ClaudeAgent: + """ + Load an agent for the given entrypoint. + + Args: + entrypoint: Name of the agent to load + + Returns: + The loaded ClaudeAgent + + Raises: + UiPathClaudeSDKRuntimeError: If the agent cannot be loaded + """ + config = self._load_config() + if not config.exists: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.CONFIG_MISSING, + "Invalid configuration", + "Failed to load claude.json configuration", + UiPathErrorCategory.DEPLOYMENT, + ) + + if entrypoint not in config.agents: + available = ", ".join(config.entrypoint) + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.AGENT_NOT_FOUND, + "Agent not found", + f"Agent '{entrypoint}' not found. Available: {available}", + UiPathErrorCategory.DEPLOYMENT, + ) + + path = config.agents[entrypoint] + agent_loader = ClaudeAgentLoader.from_path_string(entrypoint, path) + self._agent_loaders[entrypoint] = agent_loader + + try: + return await agent_loader.load() + except UiPathClaudeSDKRuntimeError: + raise + except ImportError as e: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.AGENT_IMPORT_ERROR, + "Agent import failed", + f"Failed to import agent '{entrypoint}': {str(e)}", + UiPathErrorCategory.USER, + ) from e + except Exception as e: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.AGENT_LOAD_FAILURE, + "Failed to load agent", + f"Unexpected error loading agent '{entrypoint}': {str(e)}", + UiPathErrorCategory.USER, + ) from e + + async def _resolve_agent(self, entrypoint: str) -> ClaudeAgent: + """ + Resolve an agent from configuration. + Results are cached for reuse across multiple runtime instances. + + Args: + entrypoint: Name of the agent to resolve + + Returns: + The loaded ClaudeAgent ready for execution + + Raises: + UiPathClaudeSDKRuntimeError: If resolution fails + """ + async with self._agent_lock: + if entrypoint in self._agent_cache: + return self._agent_cache[entrypoint] + + loaded_agent = await self._load_agent(entrypoint) + + self._agent_cache[entrypoint] = loaded_agent + + return loaded_agent + + def _is_conversational(self) -> bool: + """Conversational mode is defined by uipath.json (runtimeOptions.isConversational). + + At runtime the chat host also supplies a conversation_id, which takes + precedence as the live signal. + """ + if self.context.conversation_id is not None: + return True + config_path = self.context.config_path or "uipath.json" + try: + with open(config_path, "r") as f: + uipath_config = json.load(f) + except (OSError, json.JSONDecodeError): + return False + runtime_options = uipath_config.get("runtimeOptions", {}) + return bool( + isinstance(runtime_options, dict) + and runtime_options.get("isConversational") + ) + + def discover_entrypoints(self) -> list[str]: + """ + Discover all agent entrypoints. + + Returns: + List of agent names that can be used as entrypoints + """ + config = self._load_config() + if not config.exists: + return [] + return config.entrypoint + + async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: + """ + Get the shared storage instance. + """ + return await self._get_storage() + + async def get_settings(self) -> UiPathRuntimeFactorySettings | None: + """ + Get the factory settings. + + Returns: + Factory settings + """ + return UiPathRuntimeFactorySettings( + agent_type="uipath_coded", + agent_framework="claude-agent-sdk", + ) + + def _create_delegate( + self, + agent: ClaudeAgent, + runtime_id: str, + entrypoint: str, + storage: SqliteResumableStorage, + ) -> UiPathRuntimeProtocol: + """ + Create the runtime that owns the Claude SDK run loop. + + The runtime gets its own gateway telemetry, whose buffer of calls + belongs to a single run and must not be shared with another run + executing in the same process. + + Args: + agent: The loaded agent definition + runtime_id: Unique identifier for the runtime instance + entrypoint: Agent entrypoint name + storage: Shared storage instance + + Returns: + The conversational runtime when the project is conversational, + otherwise the standard runtime + """ + session_store = ClaudeSessionStore(storage, runtime_id) + runtime: UiPathClaudeSDKRuntime + + if not self._is_conversational(): + runtime = UiPathClaudeSDKRuntime( + agent=agent, + session_store=session_store, + session_paths=self._session_paths(runtime_id, entrypoint), + runtime_id=runtime_id, + entrypoint=entrypoint, + ) + else: + conversation_id = self.context.conversation_id or runtime_id + runtime = UiPathClaudeSDKConversationalRuntime( + agent=agent, + session_store=session_store, + session_paths=self._session_paths(conversation_id, entrypoint), + runtime_id=runtime_id, + entrypoint=entrypoint, + ) + + runtime.telemetry = GatewayCallTelemetry(self._tracer) + return runtime + + def _session_paths(self, workspace_id: str, entrypoint: str) -> ClaudeSessionPaths: + """Claude config and working directories for a run. + + The working directory goes beneath the directory the agent was loaded + from, so the files packaged alongside it, ``.claude`` skills above all, + are found by the CLI without being copied anywhere. + """ + paths = ClaudeSessionPaths.for_runtime( + self.context.runtime_dir, workspace_id, self._project_dir(entrypoint) + ) + logger.debug( + "Claude session paths: config_dir=%s workspace=%s", + paths.config_dir, + paths.workspace, + ) + return paths + + def _project_dir(self, entrypoint: str) -> Path | None: + """The directory the agent was loaded from, when it is known.""" + loader = self._agent_loaders.get(entrypoint) + if loader is None: + return None + return Path(os.path.abspath(loader.file_path)).parent + + async def _create_runtime_instance( + self, + agent: ClaudeAgent, + runtime_id: str, + entrypoint: str, + ) -> UiPathRuntimeProtocol: + """ + Create a runtime instance from an agent. + + Args: + agent: The loaded agent definition + runtime_id: Unique identifier for the runtime instance + entrypoint: Agent entrypoint name + + Returns: + Configured runtime instance + """ + storage = await self._get_storage() + + base_runtime = self._create_delegate( + agent=agent, + runtime_id=runtime_id, + entrypoint=entrypoint, + storage=storage, + ) + + trigger_manager = UiPathResumeTriggerHandler() + + return UiPathResumableRuntime( + delegate=base_runtime, + storage=storage, + trigger_manager=trigger_manager, + runtime_id=runtime_id, + ) + + async def new_runtime( + self, entrypoint: str, runtime_id: str, **kwargs: Any + ) -> UiPathRuntimeProtocol: + """ + Create a new Claude SDK runtime instance. + + Every runtime is wrapped in UiPathResumableRuntime, so a suspension is + persisted as a resume trigger and restored on the next resume. + + Args: + entrypoint: Agent name from claude.json + runtime_id: Unique identifier for the runtime instance + + Returns: + Configured runtime instance with agent + """ + agent = await self._resolve_agent(entrypoint) + + return await self._create_runtime_instance( + agent=agent, + runtime_id=runtime_id, + entrypoint=entrypoint, + ) + + async def dispose(self) -> None: + """Cleanup factory resources.""" + for loader in self._agent_loaders.values(): + await loader.cleanup() + + self._agent_loaders.clear() + self._agent_cache.clear() + + if self._storage: + await self._storage.dispose() + self._storage = None + + +__all__ = ["UiPathClaudeSDKRuntimeFactory"] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/__init__.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/__init__.py new file mode 100644 index 00000000..1e61e0f9 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/__init__.py @@ -0,0 +1,211 @@ +"""Explicit opt-in routing of Claude Code LLM traffic through the UiPath tenant. + +The runtime starts a ``GatewayShim`` only when the developer passes +``uipath_llm=UiPathModel(...)``. Without it nothing is injected and the Claude +Agent SDK talks to Anthropic on its own terms. +""" + +from __future__ import annotations + +import asyncio +import logging +import secrets + +from uipath.llm_client.settings import ( + UiPathBaseSettings, + get_default_client_settings, +) + +from .catalog import ( + ModelCatalog, + ResolvedModel, + ResolvedModelSet, + UiPathModelSpec, +) +from .env import build_llm_env +from .errors import ( + GatewayShimError, + ModelNotInCatalogError, + UnsupportedApiFlavorError, + to_anthropic_error, +) +from .records import GatewayCallRecord, GatewayCallSink, TraceHeaderSource +from .router import GatewayRouter +from .strategies import ( + AnthropicMessagesStrategy, + BedrockInvokeStrategy, + GatewayStrategy, + select_strategy, +) +from .upstream import UpstreamRegistry + +logger = logging.getLogger(__name__) + +STOP_TIMEOUT = 10.0 + +_API_KEY_BYTES = 32 + + +class GatewayShim: + """Local listener that routes Claude Code's LLM calls to the UiPath tenant. + + Args: + llm: The routing descriptor naming the model the developer asked for. + on_call: Optional sink invoked once per upstream call. + trace_headers: Optional source of trace context headers, letting the + gateway record the model span under the turn that caused it. + settings: Optional pre-built LLM client settings, mainly for tests. + """ + + def __init__( + self, + llm: UiPathModelSpec, + *, + on_call: GatewayCallSink | None = None, + trace_headers: TraceHeaderSource | None = None, + settings: UiPathBaseSettings | None = None, + ) -> None: + self._llm = llm + self._on_call = on_call + self._trace_headers = trace_headers + self._settings = settings + self._api_key = secrets.token_urlsafe(_API_KEY_BYTES) + self._lock = asyncio.Lock() + self._catalog: ModelCatalog | None = None + self._models: ResolvedModelSet | None = None + self._upstream: UpstreamRegistry | None = None + self._router: GatewayRouter | None = None + + # --- lifecycle -------------------------------------------------------- + + async def start(self) -> None: + """Resolve the model against discovery and bind the loopback listener. + + Idempotent: a second call on a running shim does nothing. + """ + async with self._lock: + if self._router is not None: + return + configured = self._settings + settings = ( + configured + if configured is not None + else await asyncio.to_thread(get_default_client_settings) + ) + catalog = await asyncio.to_thread(ModelCatalog.from_settings, settings) + models = catalog.resolve_set(self._llm.model) + select_strategy(models.primary) + + upstream = UpstreamRegistry(settings) + router = GatewayRouter( + catalog=catalog, + upstream=upstream, + models=models, + api_key=self._api_key, + on_call=self._on_call, + trace_headers=self._trace_headers, + ) + try: + await router.start() + except Exception: + await upstream.aclose() + raise + + self._settings = settings + self._catalog = catalog + self._models = models + self._upstream = upstream + self._router = router + logger.info( + "gateway shim routing %s to %s (%s/%s)", + self._llm.model, + models.primary.model_id, + models.primary.vendor_type, + models.primary.api_flavor, + ) + + async def stop(self) -> None: + """Drain and release the listener and every upstream client.""" + async with self._lock: + router, upstream = self._router, self._upstream + self._router = None + self._upstream = None + if router is not None: + try: + await asyncio.wait_for(router.stop(), timeout=STOP_TIMEOUT) + except asyncio.TimeoutError: + logger.warning("gateway shim did not drain within %ss", STOP_TIMEOUT) + if upstream is not None: + await upstream.aclose() + + async def __aenter__(self) -> GatewayShim: + await self.start() + return self + + async def __aexit__(self, *args: object) -> None: + await self.stop() + + # --- surface ---------------------------------------------------------- + + @property + def base_url(self) -> str: + """The loopback base URL to hand to the CLI.""" + return f"http://127.0.0.1:{self._require_router().port}" + + @property + def api_key(self) -> str: + """The per-run secret the CLI must present on every shim request.""" + return self._api_key + + @property + def resolved_model(self) -> str: + """The gateway model id to put in ``options.model``.""" + return self._require_models().primary.model_id + + @property + def resolved_models(self) -> ResolvedModelSet: + """The primary model plus the family models pinned for auxiliary traffic.""" + return self._require_models() + + @property + def catalog(self) -> ModelCatalog: + """The tenant catalog this shim resolved against.""" + if self._catalog is None: + raise RuntimeError("GatewayShim.start() has not completed") + return self._catalog + + def build_env(self) -> dict[str, str]: + """Build the CLI subprocess environment for this shim.""" + return build_llm_env(self, self._require_models()) + + def _require_router(self) -> GatewayRouter: + if self._router is None: + raise RuntimeError("GatewayShim.start() has not completed") + return self._router + + def _require_models(self) -> ResolvedModelSet: + if self._models is None: + raise RuntimeError("GatewayShim.start() has not completed") + return self._models + + +__all__ = [ + "AnthropicMessagesStrategy", + "BedrockInvokeStrategy", + "GatewayCallRecord", + "GatewayCallSink", + "GatewayRouter", + "GatewayShim", + "GatewayShimError", + "GatewayStrategy", + "ModelCatalog", + "ModelNotInCatalogError", + "ResolvedModel", + "ResolvedModelSet", + "UiPathModelSpec", + "UnsupportedApiFlavorError", + "UpstreamRegistry", + "build_llm_env", + "select_strategy", + "to_anthropic_error", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/catalog.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/catalog.py new file mode 100644 index 00000000..16fa47e0 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/catalog.py @@ -0,0 +1,274 @@ +"""Model id resolution against the tenant's LLM discovery catalog. + +Discovery reports the ids a tenant can actually reach, which are neither +stable across tenants nor the names a developer would type. The catalog maps +friendly names such as ``claude-sonnet-4-5`` onto the tenant's real id and +carries the vendor and api flavor discovery reported for it, so the routing +strategy is always chosen from discovery rather than hardcoded. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, replace +from typing import Any, Protocol + +from uipath.llm_client.settings import UiPathBaseSettings +from uipath.llm_client.settings.constants import ( + API_FLAVOR_TO_VENDOR_TYPE, + BYOM_TO_ROUTING_FLAVOR, + ApiFlavor, + VendorType, +) + +from .errors import GatewayShimError, ModelNotInCatalogError + +logger = logging.getLogger(__name__) + +FAMILIES: tuple[str, ...] = ("opus", "sonnet", "haiku") + +_VERSION_SUFFIX = re.compile(r"-\d{8}-v\d+:\d+$") +_DATE_IN_NAME = re.compile(r"[-@](\d{8})") +_VERTEX_VERSION = "v1beta1" + + +class UiPathModelSpec(Protocol): + """Structural contract the gateway needs from ``UiPathModel``. + + Only the requested model name is required. A concrete ``UiPathModel`` may + carry more, and the gateway ignores anything it does not need. + """ + + @property + def model(self) -> str: ... + + +@dataclass(frozen=True) +class ResolvedModel: + """A tenant model id plus the routing facts discovery reported for it. + + Attributes: + requested: The name the developer wrote, or the family word when the + model was found by family rather than by name. + model_id: The ``modelName`` discovery reported, e.g. + ``anthropic.claude-sonnet-4-5-20250929-v1:0``. Used for logging and + call records, never sent upstream. + wire_name: The name the request is made with. Discovery decides the + route, not the name: the other UiPath integrations look a model up + to learn its vendor and api flavor and then send the name the + developer wrote, so a name that works in ``uipath-langchain`` works + here. Family models have no developer-supplied name, so they fall + back to the catalogue id. + """ + + requested: str + model_id: str + vendor_type: str + api_flavor: str | None + api_version: str | None = None + wire_name: str = "" + + def __post_init__(self) -> None: + if not self.wire_name: + object.__setattr__(self, "wire_name", self.requested) + + +@dataclass(frozen=True) +class ResolvedModelSet: + """The primary model plus the family models used for auxiliary CLI traffic.""" + + primary: ResolvedModel + haiku: ResolvedModel + sonnet: ResolvedModel + opus: ResolvedModel + + def for_family(self, family: str) -> ResolvedModel: + """Return the resolved model for a family word, or the primary model.""" + match family: + case "haiku": + return self.haiku + case "sonnet": + return self.sonnet + case "opus": + return self.opus + case _: + return self.primary + + +class ModelCatalog: + """Alias table over the models the tenant's discovery endpoint reports.""" + + def __init__(self, models: list[dict[str, Any]]) -> None: + self._models = models + self._aliases = _build_alias_index(models) + + @classmethod + def from_settings(cls, settings: UiPathBaseSettings) -> ModelCatalog: + """Build a catalog from a discovery call. Blocking, run it off the loop.""" + return cls(settings.get_available_models()) + + @property + def available(self) -> list[str]: + """Every model name discovery reported, in the order it reported them.""" + return [str(m.get("modelName", "")) for m in self._models if m.get("modelName")] + + @property + def models(self) -> list[dict[str, Any]]: + """The raw discovery records.""" + return list(self._models) + + def resolve(self, requested: str) -> ResolvedModel: + """Resolve a developer-supplied name to a tenant model id. + + Raises: + ModelNotInCatalogError: when no alias matches. + """ + info = self._aliases.get(requested) or self._aliases.get(requested.lower()) + if info is None: + raise ModelNotInCatalogError(requested, self.available) + return describe(requested, info) + + def resolve_family(self, family: str, *, fallback: ResolvedModel) -> ResolvedModel: + """Resolve a family word to the newest matching model, or the fallback. + + A family word is ours, not the developer's, and no gateway would accept + ``haiku`` as a model, so these are the one case that goes upstream by + catalogue id. + """ + info = self._aliases.get(family) + if info is None: + return fallback + try: + described = describe(family, info) + return replace(described, wire_name=described.model_id) + except GatewayShimError: + logger.debug( + "family %s resolved to an unroutable model, using fallback", family + ) + return fallback + + def resolve_set(self, requested: str) -> ResolvedModelSet: + """Resolve the primary model and the three Claude family models.""" + primary = self.resolve(requested) + return ResolvedModelSet( + primary=primary, + haiku=self.resolve_family("haiku", fallback=primary), + sonnet=self.resolve_family("sonnet", fallback=primary), + opus=self.resolve_family("opus", fallback=primary), + ) + + +def describe(requested: str, info: dict[str, Any]) -> ResolvedModel: + """Derive vendor and api flavor for a discovery record. + + Mirrors the resolution the LLM client's own factory performs: an explicit + ``apiFlavor`` from discovery wins, the vendor fills in the flavor when + discovery leaves it unset, and the flavor implies the vendor when only the + flavor is reported. + """ + model_id = str(info.get("modelName", requested)) + discovered_flavor = info.get("apiFlavor") + + vendor = info.get("vendor") + if not vendor and discovered_flavor: + vendor = API_FLAVOR_TO_VENDOR_TYPE.get(discovered_flavor) + if not vendor: + raise ModelNotInCatalogError(requested, [model_id]) + vendor_type = str(vendor).lower() + + api_flavor: str | None = None + if discovered_flavor: + api_flavor = str( + BYOM_TO_ROUTING_FLAVOR.get(discovered_flavor, discovered_flavor) + ) + + api_version: str | None = None + if api_flavor is None: + match vendor_type: + case VendorType.AWSBEDROCK: + api_flavor = str(ApiFlavor.INVOKE) + case VendorType.VERTEXAI: + api_flavor = str(ApiFlavor.ANTHROPIC_CLAUDE) + api_version = _VERTEX_VERSION + + return ResolvedModel( + requested=requested, + model_id=model_id, + vendor_type=vendor_type, + api_flavor=api_flavor, + api_version=api_version, + ) + + +def alias_candidates(model_name: str) -> list[str]: + """Derived names a developer might reasonably type for a tenant model id.""" + lowered = model_name.lower() + stripped = lowered.removeprefix("anthropic.") + candidates = [ + model_name, + lowered, + stripped, + _VERSION_SUFFIX.sub("", lowered), + _VERSION_SUFFIX.sub("", stripped), + lowered.split("@", 1)[0], + stripped.split("@", 1)[0], + ] + seen: dict[str, None] = {} + for candidate in candidates: + if candidate: + seen.setdefault(candidate, None) + return list(seen) + + +def _build_alias_index(models: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + + for info in models: + name = info.get("modelName") + if not name: + continue + index[str(name)] = info + index.setdefault(str(name).lower(), info) + + for info in models: + name = info.get("modelName") + if not name: + continue + for alias in alias_candidates(str(name)): + index.setdefault(alias, info) + + for family in FAMILIES: + newest = _newest_in_family(models, family) + if newest is not None: + index.setdefault(family, newest) + + return index + + +def _newest_in_family( + models: list[dict[str, Any]], family: str +) -> dict[str, Any] | None: + matches = [ + info for info in models if family in str(info.get("modelName", "")).lower() + ] + if not matches: + return None + return max(matches, key=_recency_key) + + +def _recency_key(info: dict[str, Any]) -> tuple[str, str]: + name = str(info.get("modelName", "")) + dates = _DATE_IN_NAME.findall(name.lower()) + return (dates[-1] if dates else "", name) + + +__all__ = [ + "FAMILIES", + "ModelCatalog", + "ResolvedModel", + "ResolvedModelSet", + "UiPathModelSpec", + "alias_candidates", + "describe", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/env.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/env.py new file mode 100644 index 00000000..096cf834 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/env.py @@ -0,0 +1,52 @@ +"""Environment variables handed to the bundled Claude Code CLI. + +``ANTHROPIC_BASE_URL`` is the only routing channel the CLI offers, and it also +picks its own auxiliary models for background work. Every one of those is +pinned to a catalog-resolved id so no traffic escapes to a default Anthropic +model name that the tenant does not host. +""" + +from __future__ import annotations + +from typing import Protocol + +from .catalog import ResolvedModelSet + +AUXILIARY_MODEL_VARS: dict[str, str] = { + "ANTHROPIC_SMALL_FAST_MODEL": "haiku", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus", + "CLAUDE_CODE_SUBAGENT_MODEL": "primary", + "CLAUDE_CODE_BG_CLASSIFIER_MODEL": "haiku", +} + +STATIC_VARS: dict[str, str] = { + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "CLAUDE_CODE_GZIP_REQUEST_BODIES": "0", +} + + +class GatewayEndpoint(Protocol): + """The two shim facts the CLI needs in order to reach the shim.""" + + @property + def base_url(self) -> str: ... + + @property + def api_key(self) -> str: ... + + +def build_llm_env(shim: GatewayEndpoint, resolved: ResolvedModelSet) -> dict[str, str]: + """Build the CLI subprocess environment for a running shim.""" + env: dict[str, str] = { + "ANTHROPIC_BASE_URL": shim.base_url, + "ANTHROPIC_API_KEY": shim.api_key, + } + for name, family in AUXILIARY_MODEL_VARS.items(): + env[name] = resolved.for_family(family).wire_name + env.update(STATIC_VARS) + return env + + +__all__ = ["AUXILIARY_MODEL_VARS", "STATIC_VARS", "GatewayEndpoint", "build_llm_env"] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/errors.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/errors.py new file mode 100644 index 00000000..885249e6 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/errors.py @@ -0,0 +1,188 @@ +"""Anthropic-shaped error rendering for the gateway shim. + +The bundled Claude Code CLI only understands Anthropic's error envelope, so +every failure the shim reports has to be translated into that shape before it +reaches the client. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +ANTHROPIC_ERROR_TYPES: dict[int, str] = { + 400: "invalid_request_error", + 401: "authentication_error", + 403: "permission_error", + 404: "not_found_error", + 408: "timeout_error", + 413: "request_too_large", + 422: "invalid_request_error", + 429: "rate_limit_error", + 500: "api_error", + 502: "api_error", + 503: "overloaded_error", + 504: "timeout_error", + 529: "overloaded_error", +} + +_MESSAGE_KEYS = ("message", "Message", "detail", "title", "error_description") +_CORRELATION_KEYS = ("correlationId", "traceId", "requestId", "request_id") +_MAX_MESSAGE_CHARS = 2000 + + +class GatewayShimError(Exception): + """Base class for failures the shim can express as an Anthropic error.""" + + status: int = 500 + error_type: str = "api_error" + + +class ModelNotInCatalogError(GatewayShimError): + """The requested model does not resolve to any id in the tenant catalog.""" + + status = 404 + error_type = "not_found_error" + + def __init__(self, requested: str, available: Sequence[str]) -> None: + self.requested = requested + self.available = list(available) + super().__init__( + f"Model '{requested}' is not available on this UiPath tenant. " + f"Available models: {', '.join(self.available) or '(none reported by discovery)'}." + ) + + +class UnsupportedApiFlavorError(GatewayShimError): + """Discovery resolved the model to a wire format the shim cannot route.""" + + status = 400 + error_type = "invalid_request_error" + + def __init__(self, model_id: str, vendor_type: str, api_flavor: str | None) -> None: + self.model_id = model_id + self.vendor_type = vendor_type + self.api_flavor = api_flavor + super().__init__( + f"Model '{model_id}' resolves to vendor '{vendor_type}' with api flavor " + f"'{api_flavor}', which the UiPath gateway shim cannot route. Supported " + f"flavors are AnthropicMessages and invoke." + ) + + +def anthropic_error_type(status: int) -> str: + """Map an HTTP status onto the Anthropic error type name.""" + if status in ANTHROPIC_ERROR_TYPES: + return ANTHROPIC_ERROR_TYPES[status] + if 400 <= status < 500: + return "invalid_request_error" + return "api_error" + + +def error_payload( + status: int, + message: str, + *, + error_type: str | None = None, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Build an Anthropic error envelope for a known message.""" + payload: dict[str, Any] = { + "type": "error", + "error": { + "type": error_type or anthropic_error_type(status), + "message": message[:_MAX_MESSAGE_CHARS], + }, + } + if correlation_id: + payload["request_id"] = correlation_id + return payload + + +def to_anthropic_error( + status: int, + body: bytes | str | dict[str, Any] | None, + *, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Translate a gateway failure into the Anthropic error envelope. + + Preserves the gateway's own message and correlation id when the body + carries them, and falls back to the decoded body text otherwise. + """ + parsed = _parse_body(body) + message = _extract_message(parsed) + if message is None: + message = _fallback_message(body, status) + return error_payload( + status, + message, + correlation_id=correlation_id or _extract_correlation_id(parsed), + ) + + +def _parse_body(body: bytes | str | dict[str, Any] | None) -> dict[str, Any] | None: + if isinstance(body, dict): + return body + if not body: + return None + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + try: + loaded = json.loads(text) + except (ValueError, TypeError): + return None + return loaded if isinstance(loaded, dict) else None + + +def _extract_message(parsed: dict[str, Any] | None) -> str | None: + if parsed is None: + return None + inner = parsed.get("error") + if isinstance(inner, dict): + nested = _first_string(inner, _MESSAGE_KEYS) + if nested is not None: + return nested + elif isinstance(inner, str) and inner.strip(): + return inner + return _first_string(parsed, _MESSAGE_KEYS) + + +def _extract_correlation_id(parsed: dict[str, Any] | None) -> str | None: + if parsed is None: + return None + inner = parsed.get("error") + if isinstance(inner, dict): + nested = _first_string(inner, _CORRELATION_KEYS) + if nested is not None: + return nested + return _first_string(parsed, _CORRELATION_KEYS) + + +def _first_string(source: dict[str, Any], keys: Sequence[str]) -> str | None: + for key in keys: + value = source.get(key) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _fallback_message(body: bytes | str | dict[str, Any] | None, status: int) -> str: + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace").strip() + elif isinstance(body, str): + text = body.strip() + else: + text = "" + return text or f"UiPath LLM Gateway returned HTTP {status}." + + +__all__ = [ + "ANTHROPIC_ERROR_TYPES", + "GatewayShimError", + "ModelNotInCatalogError", + "UnsupportedApiFlavorError", + "anthropic_error_type", + "error_payload", + "to_anthropic_error", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/records.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/records.py new file mode 100644 index 00000000..80d876d7 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/records.py @@ -0,0 +1,161 @@ +"""Per-call telemetry emitted by the gateway shim. + +Token counts are read by teeing the SSE frames the shim is already streaming +to the client, so nothing is buffered, re-encoded or delayed on their account. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +_SSE_DELIMITER = b"\n\n" +_DATA_PREFIX = b"data:" +_MAX_PENDING_BYTES = 1_048_576 + + +@dataclass +class GatewayCallRecord: + """One upstream call through the shim, ready for a tracing consumer.""" + + requested_model: str + resolved_model: str + vendor_type: str + api_flavor: str | None + streaming: bool + status: int + duration_ms: float + input_tokens: int | None = None + output_tokens: int | None = None + cache_creation_input_tokens: int | None = None + cache_read_input_tokens: int | None = None + stop_reason: str | None = None + response_headers: dict[str, str] = field(default_factory=dict) + error: str | None = None + request_body: dict[str, Any] | None = None + output_content: list[dict[str, Any]] = field(default_factory=list) + traced_upstream: bool = False + """The call carried trace context, so the gateway owns the model span.""" + + +GatewayCallSink = Callable[[GatewayCallRecord], None] +TraceHeaderSource = Callable[[], dict[str, str]] + + +class UsageTee: + """Accumulate Anthropic usage counters from a stream passing through. + + Feed the exact bytes written to the client. Frames that are not usage + bearing are ignored, and a partial frame is carried over to the next feed. + """ + + def __init__(self) -> None: + self._pending = bytearray() + self._blocks: dict[int, dict[str, Any]] = {} + self.input_tokens: int | None = None + self.output_tokens: int | None = None + self.cache_creation_input_tokens: int | None = None + self.cache_read_input_tokens: int | None = None + self.stop_reason: str | None = None + + def feed(self, chunk: bytes) -> None: + """Consume a slice of the SSE stream.""" + self._pending.extend(chunk) + while _SSE_DELIMITER in self._pending: + frame, _, rest = bytes(self._pending).partition(_SSE_DELIMITER) + self._pending = bytearray(rest) + self._consume_frame(frame) + if len(self._pending) > _MAX_PENDING_BYTES: + self._pending.clear() + + def feed_message(self, message: Any) -> None: + """Consume a whole non-streaming Anthropic message body.""" + if not isinstance(message, dict): + return + self._absorb_usage(message.get("usage")) + stop_reason = message.get("stop_reason") + if isinstance(stop_reason, str): + self.stop_reason = stop_reason + content = message.get("content") + if isinstance(content, list) and content: + self._blocks = dict(enumerate(b for b in content if isinstance(b, dict))) + + @property + def content(self) -> list[dict[str, Any]]: + """The assistant blocks the model produced, in the order it sent them.""" + return [self._blocks[i] for i in sorted(self._blocks)] + + def _consume_frame(self, frame: bytes) -> None: + for line in frame.split(b"\n"): + if not line.startswith(_DATA_PREFIX): + continue + try: + event = json.loads(line[len(_DATA_PREFIX) :].strip()) + except ValueError: + continue + if isinstance(event, dict): + self._consume_event(event) + + def _consume_event(self, event: dict[str, Any]) -> None: + match event.get("type"): + case "message_start": + self.feed_message(event.get("message")) + case "content_block_start": + block = event.get("content_block") + if isinstance(block, dict): + self._blocks[int(event.get("index", 0))] = dict(block) + case "content_block_delta": + self._append_delta(event) + case "message_delta": + self._absorb_usage(event.get("usage")) + delta = event.get("delta") + if isinstance(delta, dict) and isinstance( + delta.get("stop_reason"), str + ): + self.stop_reason = delta["stop_reason"] + + def _append_delta(self, event: dict[str, Any]) -> None: + """Grow the block this delta belongs to, whatever kind it is.""" + delta = event.get("delta") + if not isinstance(delta, dict): + return + block = self._blocks.setdefault(int(event.get("index", 0)), {}) + for source, target in ( + ("text", "text"), + ("thinking", "thinking"), + ("partial_json", "partial_json"), + ): + piece = delta.get(source) + if isinstance(piece, str): + block[target] = str(block.get(target, "")) + piece + + def _absorb_usage(self, usage: Any) -> None: + if not isinstance(usage, dict): + return + self.input_tokens = _pick(usage, "input_tokens", self.input_tokens) + self.output_tokens = _pick(usage, "output_tokens", self.output_tokens) + self.cache_creation_input_tokens = _pick( + usage, "cache_creation_input_tokens", self.cache_creation_input_tokens + ) + self.cache_read_input_tokens = _pick( + usage, "cache_read_input_tokens", self.cache_read_input_tokens + ) + + def apply(self, record: GatewayCallRecord) -> None: + """Copy the accumulated counters onto a record.""" + record.input_tokens = self.input_tokens + record.output_tokens = self.output_tokens + record.cache_creation_input_tokens = self.cache_creation_input_tokens + record.cache_read_input_tokens = self.cache_read_input_tokens + record.stop_reason = self.stop_reason + record.output_content = self.content + + +def _pick(usage: dict[str, Any], key: str, current: int | None) -> int | None: + value = usage.get(key) + return value if isinstance(value, int) else current + + +__all__ = ["GatewayCallRecord", "GatewayCallSink", "UsageTee"] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/router.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/router.py new file mode 100644 index 00000000..35d5ea99 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/router.py @@ -0,0 +1,403 @@ +"""Local listener that routes Claude Code's HTTP calls to the UiPath gateway. + +The bundled CLI, not the Python package, makes the HTTP calls, and it POSTs a +fixed path. A loopback listener is therefore the only way to intercept them, +and ``ANTHROPIC_BASE_URL`` is the only routing channel available. +""" + +from __future__ import annotations + +import gzip +import hmac +import json +import logging +import socket +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +import httpx +from aiohttp import web +from uipath.llm_client.utils.headers import extract_matching_headers + +from .catalog import ModelCatalog, ResolvedModel, ResolvedModelSet +from .errors import ( + GatewayShimError, + error_payload, + to_anthropic_error, +) +from .records import ( + GatewayCallRecord, + GatewayCallSink, + TraceHeaderSource, + UsageTee, +) +from .strategies import GatewayStrategy, UpstreamRequest, select_strategy +from .upstream import UPSTREAM_PATH, UpstreamRegistry + +logger = logging.getLogger(__name__) + +CLIENT_MAX_SIZE = 100 * 1024 * 1024 +SHUTDOWN_TIMEOUT = 5.0 +GZIP_MAGIC = b"\x1f\x8b" +CAPTURED_HEADER_PREFIXES = ("x-uipath-", "request-id", "anthropic-ratelimit-") + +_COUNT_TOKENS_MESSAGE = ( + "The UiPath LLM Gateway does not expose a token counting endpoint, so the " + "UiPath gateway shim cannot answer /v1/messages/count_tokens." +) + + +@dataclass +class _Call: + """One in-flight request, from parse through to the emitted record.""" + + request: web.Request + client: httpx.AsyncClient + upstream: UpstreamRequest + strategy: GatewayStrategy + resolved: ResolvedModel + tee: UsageTee = field(default_factory=UsageTee) + started: float = field(default_factory=time.monotonic) + traced_upstream: bool = False + + +class GatewayRouter: + """aiohttp application bound to 127.0.0.1 on an OS-assigned port.""" + + def __init__( + self, + *, + catalog: ModelCatalog, + upstream: UpstreamRegistry, + models: ResolvedModelSet, + api_key: str, + on_call: GatewayCallSink | None = None, + trace_headers: TraceHeaderSource | None = None, + ) -> None: + self._catalog = catalog + self._upstream = upstream + self._models = models + self._api_key = api_key.encode() + self._on_call = on_call + self._trace_headers = trace_headers + self._socket: socket.socket | None = None + self._runner: web.AppRunner | None = None + self._site: web.SockSite | None = None + self._port = 0 + + @property + def port(self) -> int: + """The bound port, or 0 before the router is started.""" + return self._port + + # --- lifecycle -------------------------------------------------------- + + async def start(self) -> None: + """Bind the loopback socket and serve. The port is known before serving.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + self._port = sock.getsockname()[1] + self._socket = sock + + app = web.Application( + client_max_size=CLIENT_MAX_SIZE, + middlewares=[self._authenticate], + ) + app.router.add_get("/api/hello", self._handle_hello, allow_head=True) + app.router.add_post("/v1/messages/count_tokens", self._handle_count_tokens) + app.router.add_post("/v1/messages", self._handle_messages) + app.router.add_get("/v1/models", self._handle_models) + + self._runner = web.AppRunner(app, shutdown_timeout=SHUTDOWN_TIMEOUT) + await self._runner.setup() + self._site = web.SockSite(self._runner, sock) + await self._site.start() + except Exception: + sock.close() + self._socket = None + self._port = 0 + await self.stop() + raise + logger.info("gateway shim listening on 127.0.0.1:%d", self._port) + + async def stop(self) -> None: + """Drain in-flight requests within the shutdown timeout and release the port.""" + if self._site is not None: + await self._site.stop() + self._site = None + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + if self._socket is not None: + self._socket.close() + self._socket = None + self._port = 0 + + # --- authentication --------------------------------------------------- + + @web.middleware + async def _authenticate( + self, request: web.Request, handler: Any + ) -> web.StreamResponse: + presented = request.headers.get("x-api-key") + if not presented: + authorization = request.headers.get("Authorization", "") + scheme, _, token = authorization.partition(" ") + presented = token if scheme.lower() == "bearer" else authorization + if not hmac.compare_digest(presented.encode(), self._api_key): + return _error_response( + 401, "Invalid API key for the local UiPath gateway shim." + ) + return await handler(request) + + # --- handlers --------------------------------------------------------- + + async def _handle_hello(self, request: web.Request) -> web.Response: + return web.Response(status=200) + + async def _handle_count_tokens(self, request: web.Request) -> web.Response: + return _error_response(404, _COUNT_TOKENS_MESSAGE) + + async def _handle_models(self, request: web.Request) -> web.Response: + data = [ + {"type": "model", "id": name, "display_name": name} + for name in self._catalog.available + ] + return web.json_response({"data": data, "has_more": False}) + + async def _handle_messages(self, request: web.Request) -> web.StreamResponse: + raw = await _read_body(request) + if raw is None: + return _error_response(400, "Request body is not valid gzip.") + try: + payload = json.loads(raw) + except ValueError: + return _error_response(400, "Request body is not valid JSON.") + if not isinstance(payload, dict): + return _error_response(400, "Request body must be a JSON object.") + + try: + resolved = ( + self._catalog.resolve(str(payload["model"])) + if payload.get("model") + else self._models.primary + ) + strategy = select_strategy(resolved) + except GatewayShimError as exc: + return _error_response(exc.status, str(exc), error_type=exc.error_type) + + upstream_request = strategy.build_request(payload, request.headers, resolved) + call = _Call( + request=request, + client=await self._upstream.get(resolved), + upstream=upstream_request, + strategy=strategy, + resolved=resolved, + traced_upstream=self._attach_trace_context(upstream_request), + ) + if call.upstream.stream: + return await self._forward_stream(call) + return await self._forward_unary(call) + + # --- forwarding ------------------------------------------------------- + + async def _forward_unary(self, call: _Call) -> web.StreamResponse: + try: + upstream = await call.client.request( + "POST", + UPSTREAM_PATH, + headers=call.upstream.headers, + content=call.upstream.body, + ) + except httpx.HTTPError as exc: + self._emit(call, 502, error=str(exc)) + return _error_response(502, f"UiPath LLM Gateway is unreachable: {exc}") + + captured = _captured_headers(upstream.headers) + if upstream.status_code != 200: + self._emit( + call, upstream.status_code, error="upstream error", headers=captured + ) + return _upstream_error_response( + upstream.status_code, upstream.content, captured + ) + + try: + call.tee.feed_message(json.loads(upstream.content)) + except ValueError: + logger.debug("non-JSON body on a non-streaming gateway response") + self._emit(call, 200, headers=captured) + return web.Response( + status=200, body=upstream.content, content_type="application/json" + ) + + async def _forward_stream(self, call: _Call) -> web.StreamResponse: + try: + async with call.client.stream( + "POST", + UPSTREAM_PATH, + headers=call.upstream.headers, + content=call.upstream.body, + ) as upstream: + captured = _captured_headers(upstream.headers) + if upstream.status_code != 200: + body = await upstream.aread() + self._emit( + call, + upstream.status_code, + error="upstream error", + headers=captured, + ) + return _upstream_error_response( + upstream.status_code, body, captured + ) + return await self._pump(call, upstream, captured) + except httpx.HTTPError as exc: + self._emit(call, 502, error=str(exc)) + return _error_response(502, f"UiPath LLM Gateway is unreachable: {exc}") + + async def _pump( + self, + call: _Call, + upstream: httpx.Response, + captured: dict[str, str], + ) -> web.StreamResponse: + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + ) + await response.prepare(call.request) + try: + async for chunk in call.strategy.stream_response(upstream): + call.tee.feed(chunk) + await response.write(chunk) + except Exception as exc: + logger.error("gateway stream failed mid-response: %s", exc) + await _write_stream_error(response, exc) + self._emit(call, 502, error=str(exc), headers=captured) + else: + self._emit(call, 200, headers=captured) + await response.write_eof() + return response + + # --- telemetry -------------------------------------------------------- + + def _attach_trace_context(self, upstream: UpstreamRequest) -> bool: + """Let the gateway record the model span under the turn that caused it. + + The gateway builds its own span from these headers, so a call that + carries them must not also be given one here. + """ + if self._trace_headers is None: + return False + try: + headers = self._trace_headers() + except Exception: + logger.debug("trace context headers unavailable", exc_info=True) + return False + if not headers: + return False + upstream.headers.update(headers) + return True + + def _emit( + self, + call: _Call, + status: int, + *, + error: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + if self._on_call is None: + return + record = GatewayCallRecord( + requested_model=call.resolved.requested, + resolved_model=call.resolved.model_id, + vendor_type=call.resolved.vendor_type, + api_flavor=call.resolved.api_flavor, + streaming=call.upstream.stream, + status=status, + duration_ms=(time.monotonic() - call.started) * 1000, + response_headers=headers or {}, + error=error, + request_body=_decode_request(call.upstream.body), + traced_upstream=call.traced_upstream, + ) + call.tee.apply(record) + try: + self._on_call(record) + except Exception: + logger.debug("gateway call sink raised", exc_info=True) + + +def _decode_request(body: bytes) -> dict[str, Any] | None: + """The request as sent upstream, for the tracing consumer.""" + try: + payload = json.loads(body) + except ValueError: + return None + return payload if isinstance(payload, dict) else None + + +# --- helpers -------------------------------------------------------------- + + +async def _read_body(request: web.Request) -> bytes | None: + """Read the request body, decompressing gzip aiohttp left encoded.""" + raw = await request.read() + if not raw.startswith(GZIP_MAGIC): + return raw + try: + return gzip.decompress(raw) + except (OSError, EOFError): + return None + + +def _captured_headers(headers: Mapping[str, str]) -> dict[str, str]: + return extract_matching_headers(httpx.Headers(headers), CAPTURED_HEADER_PREFIXES) + + +def _correlation_id(headers: Mapping[str, str]) -> str | None: + for name, value in headers.items(): + if name.lower().endswith(("correlation-id", "request-id")): + return value + return None + + +def _error_response( + status: int, message: str, *, error_type: str | None = None +) -> web.Response: + return web.json_response( + error_payload(status, message, error_type=error_type), status=status + ) + + +def _upstream_error_response( + status: int, body: bytes, headers: dict[str, str] +) -> web.Response: + payload = to_anthropic_error(status, body, correlation_id=_correlation_id(headers)) + return web.json_response(payload, status=status) + + +async def _write_stream_error(response: web.StreamResponse, exc: Exception) -> None: + """Report a mid-stream failure inside the already committed SSE stream. + + Once the response is prepared the status is on the wire, so the only way to + reach the CLI's error path is an ``error`` frame. + """ + payload = error_payload(502, f"UiPath LLM Gateway stream failed: {exc}") + frame = b"event: error\ndata: " + json.dumps(payload).encode() + b"\n\n" + try: + await response.write(frame) + except (ConnectionResetError, RuntimeError): + logger.debug("client gone before the stream error frame was written") + + +__all__ = ["CAPTURED_HEADER_PREFIXES", "CLIENT_MAX_SIZE", "GatewayRouter"] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/strategies.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/strategies.py new file mode 100644 index 00000000..2475d5d3 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/strategies.py @@ -0,0 +1,225 @@ +"""Wire-format strategies for forwarding a Claude Code request upstream. + +The shim is a router, not a protocol translator. Which strategy applies is +decided by the api flavor discovery reported for the resolved model, never by +a hardcoded assumption about how a tenant hosts Claude. +""" + +from __future__ import annotations + +import base64 +import json +import logging +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx +from uipath.llm_client.settings.constants import ApiFlavor, VendorType + +from .catalog import ResolvedModel +from .errors import UnsupportedApiFlavorError + +logger = logging.getLogger(__name__) + +BEDROCK_ANTHROPIC_VERSION = "bedrock-2023-05-31" +BEDROCK_EVENTSTREAM_CONTENT_TYPE = "application/vnd.amazon.eventstream" + +FORWARDED_REQUEST_HEADERS: tuple[str, ...] = ( + "anthropic-version", + "anthropic-beta", + "content-type", +) + +_BEDROCK_TRANSPORT_FIELDS: frozenset[str] = frozenset({"model", "stream"}) + +UNSUPPORTED_UPSTREAM_FIELDS: tuple[str, ...] = ("context_management",) +"""Request fields the CLI sends that the gateway rejects outright. + +The gateway validates the body strictly, so an unknown field fails the whole +call with ``400 Extra inputs are not permitted`` rather than being ignored. +These are dropped and the drop is logged once per run, because silently +reshaping a request is how the proxy this shim replaced lost prompt caching +without anyone noticing. + +Keep this list as short as the evidence justifies. A field belongs here only +after a gateway has actually rejected it. +""" + + +_pruned_once: set[str] = set() + + +def prune_unsupported(body: dict[str, Any]) -> dict[str, Any]: + """Drop request fields the gateway cannot accept, warning the first time. + + A strategy is built per request, so the warning is tracked per process + rather than per instance. + """ + dropped = [name for name in UNSUPPORTED_UPSTREAM_FIELDS if name in body] + if not dropped: + return body + for name in dropped: + del body[name] + fresh = [name for name in dropped if name not in _pruned_once] + if fresh: + _pruned_once.update(fresh) + logger.warning( + "Dropped %s from the request because the UiPath LLM Gateway rejects " + "unknown fields. The Claude Code CLI sends this and the feature it " + "drives is unavailable through the gateway.", + ", ".join(sorted(fresh)), + ) + return body + + +@dataclass(frozen=True) +class UpstreamRequest: + """Everything the router needs to issue the upstream call.""" + + body: bytes + headers: dict[str, str] + stream: bool + + +class GatewayStrategy(Protocol): + """How one wire format is forwarded to and streamed back from the gateway.""" + + name: str + + def build_request( + self, + payload: dict[str, Any], + headers: Mapping[str, str], + resolved: ResolvedModel, + ) -> UpstreamRequest: + """Turn the CLI's request into the upstream request.""" + ... + + def stream_response(self, upstream: httpx.Response) -> AsyncIterator[bytes]: + """Yield Anthropic SSE bytes for the client.""" + ... + + +class AnthropicMessagesStrategy: + """Near pass-through for gateways that speak the Anthropic Messages format. + + The body is forwarded apart from the model id and the handful of fields in + ``UNSUPPORTED_UPSTREAM_FIELDS``, and the response stream is copied byte for + byte, so beta features, prompt caching and any field the CLI adds in a + future release keep working without a change here. + """ + + name = "AnthropicMessages" + + def build_request( + self, + payload: dict[str, Any], + headers: Mapping[str, str], + resolved: ResolvedModel, + ) -> UpstreamRequest: + body = prune_unsupported(dict(payload)) + body["model"] = resolved.wire_name + return UpstreamRequest( + body=json.dumps(body).encode(), + headers=forwarded_headers(headers), + stream=bool(payload.get("stream", False)), + ) + + async def stream_response(self, upstream: httpx.Response) -> AsyncIterator[bytes]: + async for chunk in upstream.aiter_bytes(): + yield chunk + + +class BedrockInvokeStrategy: + """Translation for tenants whose discovery still reports the Bedrock invoke flavor. + + Bedrock's InvokeModel carries the model in the URL and the api version in + the body, and answers a streaming call with binary Event Stream frames that + have to be re-emitted as SSE. + """ + + name = "invoke" + + def build_request( + self, + payload: dict[str, Any], + headers: Mapping[str, str], + resolved: ResolvedModel, + ) -> UpstreamRequest: + body = prune_unsupported( + {k: v for k, v in payload.items() if k not in _BEDROCK_TRANSPORT_FIELDS} + ) + body["anthropic_version"] = BEDROCK_ANTHROPIC_VERSION + return UpstreamRequest( + body=json.dumps(body).encode(), + headers=forwarded_headers(headers), + stream=bool(payload.get("stream", False)), + ) + + async def stream_response(self, upstream: httpx.Response) -> AsyncIterator[bytes]: + content_type = upstream.headers.get("content-type", "") + if "amazon.eventstream" not in content_type: + async for chunk in upstream.aiter_bytes(): + yield chunk + return + + from botocore.eventstream import EventStreamBuffer + + buffer = EventStreamBuffer() + async for chunk in upstream.aiter_bytes(): + buffer.add_data(chunk) + for message in buffer: + frame = _decode_eventstream_payload(message.payload) + if frame is not None: + yield frame + + +def forwarded_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Pick the inbound headers that belong on the upstream request.""" + forwarded = { + name: headers[name] for name in FORWARDED_REQUEST_HEADERS if name in headers + } + forwarded.setdefault("content-type", "application/json") + return forwarded + + +def select_strategy(resolved: ResolvedModel) -> GatewayStrategy: + """Pick the strategy for a resolved model from its discovered api flavor.""" + if resolved.api_flavor == ApiFlavor.ANTHROPIC_MESSAGES: + return AnthropicMessagesStrategy() + if resolved.api_flavor == ApiFlavor.INVOKE: + return BedrockInvokeStrategy() + if resolved.api_flavor is None and resolved.vendor_type in ( + VendorType.ANTHROPIC, + VendorType.AZURE, + ): + return AnthropicMessagesStrategy() + raise UnsupportedApiFlavorError( + resolved.model_id, resolved.vendor_type, resolved.api_flavor + ) + + +def _decode_eventstream_payload(payload: bytes) -> bytes | None: + try: + envelope = json.loads(payload) + inner = base64.b64decode(envelope["bytes"]) + event = json.loads(inner) + except (KeyError, TypeError, ValueError): + return None + event_type = event.get("type", "message") if isinstance(event, dict) else "message" + return b"event: " + str(event_type).encode() + b"\ndata: " + inner + b"\n\n" + + +__all__ = [ + "BEDROCK_ANTHROPIC_VERSION", + "FORWARDED_REQUEST_HEADERS", + "UNSUPPORTED_UPSTREAM_FIELDS", + "AnthropicMessagesStrategy", + "BedrockInvokeStrategy", + "GatewayStrategy", + "UpstreamRequest", + "forwarded_headers", + "prune_unsupported", + "select_strategy", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/upstream.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/upstream.py new file mode 100644 index 00000000..4966d516 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/gateway/upstream.py @@ -0,0 +1,90 @@ +"""Upstream HTTP clients for the UiPath LLM Gateway. + +``UiPathHttpxAsyncClient`` already supplies endpoint selection, the refreshing +auth pipeline, org and tenant routing headers, retries and the UiPath SSL +defaults, so the shim configures one per resolved model and adds nothing. +""" + +from __future__ import annotations + +import asyncio +import logging + +import httpx +from uipath.llm_client import UiPathHttpxAsyncClient +from uipath.llm_client.settings import ( + ApiType, + RoutingMode, + UiPathAPIConfig, + UiPathBaseSettings, +) + +from .catalog import ResolvedModel + +logger = logging.getLogger(__name__) + +UPSTREAM_PATH = "/" +UPSTREAM_TIMEOUT = httpx.Timeout(timeout=900.0, connect=30.0) + + +def build_api_config(resolved: ResolvedModel) -> UiPathAPIConfig: + """Build the passthrough routing configuration for a resolved model.""" + return UiPathAPIConfig( + api_type=ApiType.COMPLETIONS, + routing_mode=RoutingMode.PASSTHROUGH, + vendor_type=resolved.vendor_type, + api_flavor=resolved.api_flavor, + api_version=resolved.api_version, + freeze_base_url=True, + ) + + +class UpstreamRegistry: + """Lazily built, per-model upstream clients with a single shutdown point.""" + + def __init__(self, settings: UiPathBaseSettings) -> None: + self._settings = settings + self._clients: dict[str, UiPathHttpxAsyncClient] = {} + self._lock = asyncio.Lock() + + async def get(self, resolved: ResolvedModel) -> UiPathHttpxAsyncClient: + """Return the client for a resolved model, building it on first use.""" + client = self._clients.get(resolved.model_id) + if client is not None: + return client + async with self._lock: + client = self._clients.get(resolved.model_id) + if client is None: + client = self._build(resolved) + self._clients[resolved.model_id] = client + logger.debug( + "upstream client ready for %s (%s/%s) at %s", + resolved.model_id, + resolved.vendor_type, + resolved.api_flavor, + client.base_url, + ) + return client + + def _build(self, resolved: ResolvedModel) -> UiPathHttpxAsyncClient: + return UiPathHttpxAsyncClient( + model_name=resolved.wire_name, + client_settings=self._settings, + api_config=build_api_config(resolved), + timeout=UPSTREAM_TIMEOUT, + logger=logger, + ) + + async def aclose(self) -> None: + """Close every client this registry created.""" + async with self._lock: + clients = list(self._clients.values()) + self._clients.clear() + for client in clients: + try: + await client.aclose() + except Exception: + logger.debug("upstream client close failed", exc_info=True) + + +__all__ = ["UPSTREAM_PATH", "UPSTREAM_TIMEOUT", "UpstreamRegistry", "build_api_config"] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/loader.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/loader.py new file mode 100644 index 00000000..1069dfa9 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/loader.py @@ -0,0 +1,167 @@ +"""Dynamic loader for ClaudeAgent definitions (e.g. 'main.py:agent').""" + +from __future__ import annotations + +import hashlib +import importlib.util +import inspect +import logging +import os +import sys +from pathlib import Path +from typing import Any, Self + +from claude_agent_sdk import ClaudeAgentOptions +from uipath.runtime.errors import UiPathErrorCategory + +from ..agent import ClaudeAgent +from .errors import ( + UiPathClaudeSDKErrorCode, + UiPathClaudeSDKRuntimeError, +) + +logger = logging.getLogger(__name__) + + +class ClaudeAgentLoader: + """Load a ClaudeAgent from a Python file path (e.g. 'main.py:agent').""" + + def __init__(self, name: str, file_path: str, variable_name: str): + self.name = name + self.file_path = file_path + self.variable_name = variable_name + self._context_manager: Any = None + + @classmethod + def from_path_string(cls, name: str, file_path: str) -> Self: + """Create a loader from a 'file_path:variable_name' string.""" + if ":" not in file_path: + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.CONFIG_INVALID, + title="Invalid agent path format", + detail=f"Invalid path format '{file_path}'. Expected format 'file_path:variable_name'.", + category=UiPathErrorCategory.USER, + ) + file, variable = file_path.split(":", 1) + return cls(name=name, file_path=file, variable_name=variable) + + async def load(self) -> ClaudeAgent: + """Load and return the agent definition. + + Raises: + UiPathClaudeSDKRuntimeError: If loading fails. + """ + cwd = os.path.abspath(os.getcwd()) + abs_file_path = os.path.abspath(os.path.normpath(self.file_path)) + + if not Path(abs_file_path).is_relative_to(Path(cwd)): + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_VALUE_ERROR, + title="Invalid agent file path", + detail=f"Agent file path '{self.file_path}' must be within the current working directory.", + category=UiPathErrorCategory.USER, + ) + + if not os.path.exists(abs_file_path): + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_NOT_FOUND, + title="Agent file not found", + detail=f"Agent file '{self.file_path}' does not exist.", + category=UiPathErrorCategory.USER, + ) + + self._setup_python_path(cwd) + module = self._import_module(abs_file_path) + + agent_object = getattr(module, self.variable_name, None) + if agent_object is None: + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_NOT_FOUND, + title="Agent variable not found", + detail=f"'{self.variable_name}' not found in module '{self.file_path}'.", + category=UiPathErrorCategory.USER, + ) + + agent = await self._resolve_agent(agent_object) + + # Lowest-friction path: a bare ClaudeAgentOptions export is wrapped + # in a ClaudeAgent with defaults. + if isinstance(agent, ClaudeAgentOptions): + agent = ClaudeAgent(options=agent, name=self.name) + + if not isinstance(agent, ClaudeAgent): + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_TYPE_ERROR, + title="Invalid agent type", + detail=( + f"Expected uipath_claude_sdk.ClaudeAgent or " + f"claude_agent_sdk.ClaudeAgentOptions, got '{type(agent).__name__}'." + ), + category=UiPathErrorCategory.USER, + ) + + return agent + + def _setup_python_path(self, cwd: str) -> None: + if cwd not in sys.path: + sys.path.insert(0, cwd) + + # Support src-layout projects (mimics editable install) + src_dir = os.path.join(cwd, "src") + if os.path.isdir(src_dir) and src_dir not in sys.path: + sys.path.insert(0, src_dir) + + def _import_module(self, abs_file_path: str) -> Any: + digest = hashlib.sha256(abs_file_path.encode()).hexdigest()[:12] + module_name = f"{Path(abs_file_path).stem}_{digest}" + spec = importlib.util.spec_from_file_location(module_name, abs_file_path) + + if not spec or not spec.loader: + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_IMPORT_ERROR, + title="Failed to load agent module", + detail=f"Could not load module from: {abs_file_path}", + category=UiPathErrorCategory.USER, + ) + + try: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + except Exception as e: + raise UiPathClaudeSDKRuntimeError( + code=UiPathClaudeSDKErrorCode.AGENT_LOAD_FAILURE, + title="Failed to execute agent module", + detail=f"Error loading module from {abs_file_path}: {str(e)}", + category=UiPathErrorCategory.USER, + ) from e + + async def _resolve_agent(self, agent_object: Any) -> Any: + """Resolve direct instances, (async) factory callables, and async context managers.""" + if callable(agent_object) and not isinstance( + agent_object, (ClaudeAgent, ClaudeAgentOptions) + ): + if inspect.iscoroutinefunction(agent_object): + agent_instance = await agent_object() + else: + agent_instance = agent_object() + else: + agent_instance = agent_object + + if hasattr(agent_instance, "__aenter__") and callable( + agent_instance.__aenter__ + ): + self._context_manager = agent_instance + return await agent_instance.__aenter__() + + return agent_instance + + async def cleanup(self) -> None: + if self._context_manager: + try: + await self._context_manager.__aexit__(None, None, None) + except Exception as e: + logger.warning("Error during agent cleanup: %s", e) + finally: + self._context_manager = None diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/runtime.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/runtime.py new file mode 100644 index 00000000..4d8749cb --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/runtime.py @@ -0,0 +1,963 @@ +"""Runtime class for executing Claude Agent SDK agents within the UiPath framework.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncGenerator +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from claude_agent_sdk import ( + TERMINAL_TASK_STATUSES, + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + HookMatcher, + Message, + ResultMessage, + SystemMessage, + TaskNotificationMessage, + TaskProgressMessage, + TaskStartedMessage, + TaskUpdatedMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from opentelemetry import context as context_api +from pydantic import ValidationError +from uipath.core.serialization import serialize_json +from uipath.core.tracing import traced +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.errors import UiPathErrorCategory +from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent +from uipath.runtime.schema import UiPathRuntimeSchema + +from ..agent import ClaudeAgent +from ..interrupts import ( + PendingSuspend, + SuspendChannel, + ToolIndex, + active_channel, + uipath_tool_index, +) +from ._telemetry import GatewayCallTelemetry +from .errors import UiPathClaudeSDKErrorCode, UiPathClaudeSDKRuntimeError +from .gateway import GatewayShim, GatewayShimError +from .schema import get_agent_graph, get_entrypoints_schema +from .session_paths import ClaudeSessionPaths +from .session_store import ClaudeSessionStore, EntrypointRecord, TranscriptRecord +from .suspend import build_suspend_hooks + +logger = logging.getLogger(__name__) +cli_logger = logger.getChild("cli") + +RESUME_PROMPT = "Continue." + +# These are the bounded task types whose completion causes Claude Code to run a follow-up turn. +_WAITED_BACKGROUND_TASK_TYPES = frozenset({"local_agent", "local_workflow"}) + + +def _resume_shape_only(inputs: dict[str, Any]) -> dict[str, Any]: + """Say whether this run is resuming, without recording the payload. + + The input a resume carries is the answer a person or a platform trigger + gave, so it is exactly the kind of thing not to copy onto a span. + """ + return {"resuming": inputs.get("resuming")} + + +def log_cli_stderr(line: str) -> None: + """Route one line of the CLI subprocess's stderr into the runtime's logs. + + The SDK pipes that stream only when a callback is registered, so without + this the CLI's own diagnostics are inherited by the parent process: visible + in a local terminal and nowhere else, not in execution.log, not in the dev + server output, and not in a deployed job's logs. The SDK swallows anything + this raises and drops nothing else, so it stays a single log call. + """ + cli_logger.warning("claude cli: %s", line) + + +_SUSPEND_SAFETY_ENV = {"CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK": "1"} +"""Environment the CLI subprocess needs for a suspension to be trustworthy. + +On the non-streaming fallback the assistant message keeps its full content +array, which trips the CLI's solo-only guard: the defer is discarded and the +tool executes for real while the run reports success. Disabling the fallback +turns that into a loud API error instead. It is applied only to an agent that +can suspend, so an agent without UiPath tools +keeps the CLI's own retry behaviour. +""" + + +@dataclass +class RunContext: + """Per-invocation state shared between the run loop and its helpers. + + Attributes: + channel: The suspend channel this run's hook and UiPath tools share. + sdk_options: Options handed to ``ClaudeSDKClient``. + session_id: Claude session the SDK was asked to re-attach to, if any. + pending: The deferred call this run is resolving, if any. + resuming: Whether this invocation is resuming a suspended job. + """ + + channel: SuspendChannel + sdk_options: ClaudeAgentOptions + session_id: str | None + pending: PendingSuspend | None + resuming: bool = False + + +class UiPathClaudeSDKRuntime: + """A runtime class for executing Claude Agent SDK agents within the UiPath framework. + + Owns the ``ClaudeSDKClient`` run loop and maps SDK messages to UiPath + runtime events. LLM routing is an explicit opt-in: an agent declaring + ``uipath_llm`` runs behind a local gateway shim pointed at the tenant, + and an agent without one gets no injected LLM environment at all. + + Suspension is opt-in too. A run suspends durably when a tool the developer + built with ``uipath_tool_server`` calls ``interrupt``: the CLI parks the + call, the runtime persists the pending record and returns SUSPENDED, and a + later process rebuilds the same hook, seeds the trigger payload and + re-attaches to the Claude session so the parked call finishes with a real + tool result. + + Args: + agent: The loaded ClaudeAgent definition. + session_store: Persists the Claude session id, the pending suspension + and the entrypoint the run started on. + session_paths: Durable Claude config and working directories, both + inside the runtime directory so they survive a suspension. + runtime_id: Unique identifier for this runtime instance. + entrypoint: Agent entrypoint name (for schema reporting). + """ + + def __init__( + self, + agent: ClaudeAgent, + session_store: ClaudeSessionStore, + session_paths: ClaudeSessionPaths, + runtime_id: str | None = None, + entrypoint: str | None = None, + ): + self.agent = agent + self.runtime_id: str = runtime_id or "default" + self.entrypoint: str | None = entrypoint + self._session_store = session_store + self._session_paths = session_paths + self._shim: GatewayShim | None = None + # Assigned by the runtime factory, replaceable by a host. + self.telemetry: GatewayCallTelemetry | None = None + + # --- Instrumentation --------------------------------------------------- + + def _adopt_span_parent(self, channel: SuspendChannel | None = None) -> None: + """Give the turn to everything that traces outside its context. + + Only valid while the instrumentor's agent span is current, which is + during iteration of the response. The gateway shim and the tool bodies + both run in tasks that never see that span, so each is handed the + context to attach instead. + """ + current = context_api.get_current() + if self.telemetry is not None: + self.telemetry.adopt_parent(current) + if channel is not None: + channel.adopt_turn_parent(current) + + def _flush_gateway_spans(self) -> None: + """Turn the run's upstream calls into the only LLM spans it emits. + + The agent and tool spans come from the Claude Agent SDK instrumentor, + which sees no model call to report: the CLI makes them in its own + process, so the gateway shim is the only observer of the real traffic. + """ + if self.telemetry is not None: + self.telemetry.flush_gateway_calls() + + # --- LLM access ------------------------------------------------------- + + async def _build_llm_env(self) -> dict[str, str]: + """Return the SDK subprocess env vars for LLM access. + + With ``agent.uipath_llm`` set, a gateway shim is started and the CLI + is pointed at it. Without it nothing is injected and the Claude Agent + SDK reaches Anthropic on whatever credentials it finds for itself. + """ + llm = self.agent.uipath_llm + if llm is None: + logger.info( + "LLM routing for '%s': direct Anthropic access, the agent declares " + "no uipath_llm so no LLM environment is injected.", + self.agent.name, + ) + return {} + + telemetry = self.telemetry + if self._shim is None: + self._shim = GatewayShim( + llm, + on_call=telemetry.on_gateway_call if telemetry else None, + trace_headers=telemetry.trace_context_headers if telemetry else None, + ) + try: + await self._shim.start() + except Exception: + await self._stop_llm_gateway() + raise + logger.info( + "LLM routing for '%s': UiPath LLM Gateway, requested model '%s' " + "resolved to '%s' behind %s.", + self.agent.name, + llm.model, + self._shim.resolved_model, + self._shim.base_url, + ) + return self._shim.build_env() + + async def _stop_llm_gateway(self) -> None: + """Stop the gateway shim if this runtime started one.""" + shim, self._shim = self._shim, None + if shim is None: + return + try: + await shim.stop() + except Exception as e: + logger.warning("Failed to stop the gateway shim cleanly: %s", e) + + # --- Input/output mapping --------------------------------------------- + + def _get_user_message(self, input: dict[str, Any]) -> str: + """Build the user message string for client.query(). + + Renders the agent's prompt template from validated input fields when + declared; otherwise uses the 'input' field or a JSON dump of the input. + """ + validated: dict[str, Any] = input + if self.agent.input_schema is not None: + try: + model = self.agent.input_schema.model_validate(input) + except ValidationError as e: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.INPUT_VALIDATION_ERROR, + "Invalid input", + f"Input does not match the agent's input schema: {e}", + UiPathErrorCategory.USER, + ) from e + validated = model.model_dump() + + if self.agent.prompt: + try: + return self.agent.prompt.format(**validated) + except (KeyError, IndexError) as e: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.INPUT_VALIDATION_ERROR, + "Invalid prompt template", + f"Failed to render prompt template from input: {e}", + UiPathErrorCategory.USER, + ) from e + + if isinstance(validated.get("input"), str): + return validated["input"] + return json.dumps(validated) + + def _map_result_output(self, message: ResultMessage) -> dict[str, Any]: + if self.agent.output_schema is not None: + structured = message.structured_output + try: + validated = self.agent.output_schema.model_validate(structured or {}) + except ValidationError as e: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.OUTPUT_VALIDATION_ERROR, + "Invalid structured output", + f"Agent output does not match the declared output schema: {e}", + UiPathErrorCategory.USER, + ) from e + return json.loads(serialize_json(validated)) + # SDK-native structured output: options.output_format set by the user. + if self.agent.options.output_format is not None and isinstance( + message.structured_output, dict + ): + return message.structured_output + logger.warning( + "Entrypoint '%s' declares no output_schema, returning the agent's final " + "text under 'result'. Set output_schema on the agent for typed output.", + self.entrypoint or self.agent.name, + ) + return {"result": message.result or ""} + + def create_runtime_error(self, e: Exception) -> Exception: + """Map a raw exception before it is re-raised from stream().""" + if isinstance(e, UiPathClaudeSDKRuntimeError): + return e + if isinstance(e, GatewayShimError): + return UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.GATEWAY_PROXY_ERROR, + "LLM gateway routing failed", + f"Error: {e}", + UiPathErrorCategory.USER, + ) + if isinstance(e, TimeoutError): + return UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.AGENT_TIMEOUT, + "Agent execution timed out", + f"Error: {e}", + UiPathErrorCategory.USER, + ) + return UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.AGENT_EXECUTION_ERROR, + "Agent execution failed", + f"Error: {e}", + UiPathErrorCategory.USER, + ) + + # --- SDK options ------------------------------------------------------ + + @staticmethod + def _warn_on_overridden_safety_env( + declared: dict[str, str] | None, safety_env: dict[str, str] + ) -> None: + """Say so when a declared value is about to lose to a safety value. + + Silently winning would leave a developer reading their own env back and + seeing something else run. + """ + for name, required in safety_env.items(): + declared_value = (declared or {}).get(name) + if declared_value is not None and declared_value != required: + logger.warning( + "%s=%s was declared but a suspending agent needs %s, so the " + "declared value is ignored.", + name, + declared_value, + required, + ) + + def _build_sdk_options( + self, + *, + env: dict[str, str], + workspace: Path, + channel: SuspendChannel, + resume_session_id: str | None = None, + ) -> ClaudeAgentOptions: + """Derive execution options from the user's options. + + Injects execution-scoped fields (env, cwd, model, output_format, + resume) while preserving everything the developer configured. + ``setting_sources`` defaults to [] to isolate from user/project/local + Claude config. The suspend hook is added only when the agent registered + UiPath tools, so an agent that declares none gets the tools, hooks and + prompt it wrote and nothing else. + """ + base = self.agent.options + tool_index = uipath_tool_index(base.mcp_servers) + safety_env = _SUSPEND_SAFETY_ENV if tool_index else {} + self._warn_on_overridden_safety_env(base.env, safety_env) + overrides: dict[str, Any] = { + "env": {**base.env, **env, **safety_env}, + "cwd": workspace, + } + if self._shim is not None: + overrides["model"] = self._shim.resolved_model + if base.stderr is None: + overrides["stderr"] = log_cli_stderr + if base.setting_sources is None: + overrides["setting_sources"] = [] + if self.agent.output_schema is not None and base.output_format is None: + overrides["output_format"] = { + "type": "json_schema", + "schema": self.agent.output_schema.model_json_schema(), + } + if resume_session_id is not None: + overrides["resume"] = resume_session_id + self._wire_suspend_hook(base, overrides, channel, tool_index) + return replace(base, **overrides) + + @staticmethod + def _wire_suspend_hook( + base: ClaudeAgentOptions, + overrides: dict[str, Any], + channel: SuspendChannel, + tool_index: ToolIndex, + ) -> None: + """Add the ``PreToolUse`` hook that runs the agent's UiPath tool bodies. + + Every process that runs or resumes a session rebuilds it: hooks are live + objects and cannot be persisted. Nothing else is injected, so an agent + with no UiPath tools is handed the options it declared. + """ + if not tool_index: + return + + hooks: dict[str, list[HookMatcher]] = { + event: list(matchers) for event, matchers in (base.hooks or {}).items() + } + for event, matchers in build_suspend_hooks(channel, tool_index).items(): + hooks[event] = [*matchers, *hooks.get(event, [])] + overrides["hooks"] = hooks + + # --- Suspension lifecycle ---------------------------------------------- + + async def _session_id_for_run(self, resuming: bool) -> str | None: + """The Claude session this run re-attaches to. + + A resume without a stored session id would silently start a fresh + conversation that has forgotten the parked call, so it fails instead. + """ + if not resuming: + return None + session_id = await self._session_store.get_session_id() + if session_id is None: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.SESSION_NOT_FOUND, + "No Claude session to resume", + f"Runtime '{self.runtime_id}' was asked to resume but no Claude " + "session id was persisted. The runtime directory holding the " + "session state was not remounted, or the run never started.", + UiPathErrorCategory.SYSTEM, + ) + return session_id + + @traced( + name="claude_suspend_restore", + run_type="uipath", + input_processor=_resume_shape_only, + hide_output=True, + ) + async def _restore_pending( + self, + channel: SuspendChannel, + input: dict[str, Any] | None, + resuming: bool, + ) -> PendingSuspend | None: + """Seed the resume payload for the parked call, when this run resolves one. + + The resumable wrapper hands back either the ``{interrupt_id: payload}`` + map it built from a fired trigger, or, when the caller passed input + explicitly, that input unwrapped. Both shapes have to resolve the parked + call, otherwise ``uipath run agent '{"answer": "..."}' --resume`` drops + the answer and suspends again under a fresh interrupt id. + + A resume that carries nothing yet restores the record without resolving + it, so the re-parked call keeps its interrupt id instead of minting a + second trigger for the same wait. + """ + if not resuming: + return None + + pending = await self._session_store.get_pending_suspend() + if pending is None: + return None + if not input: + logger.info( + "Interrupt %s is still pending and this resume carries no payload, " + "so the parked call stays parked.", + pending.interrupt_id, + ) + channel.restore(pending) + return None + + payload = ( + input[pending.interrupt_id] if pending.interrupt_id in input else input + ) + await self._check_entrypoint() + channel.restore(pending) + channel.resolve(pending.interrupt_id, payload) + return pending + + async def _check_entrypoint(self) -> None: + """Refuse to resume a session that was started on another entrypoint.""" + record = await self._session_store.get_entrypoint() + if record is None or self.entrypoint is None: + return + if record.entrypoint == self.entrypoint: + return + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.ENTRYPOINT_MISMATCH, + "Resumed on a different entrypoint", + f"The suspended run started on '{record.entrypoint}' and cannot be " + f"resumed on '{self.entrypoint}'.", + UiPathErrorCategory.USER, + ) + + @staticmethod + def _refuse_breakpoints(options: UiPathStreamOptions | None) -> None: + """Answer a breakpoint request out loud instead of ignoring it. + + The agent loop runs inside the claude CLI subprocess, so this runtime + owns no node it could hold execution at, and the graph it reports has + no node to name either. ``uipath debug`` still offers stepping and + node breakpoints, and a debugger that accepts them and then runs + straight through looks broken rather than unsupported. + """ + if options is None or not options.breakpoints: + return + logger.warning( + "Breakpoints are not supported by the Claude SDK runtime, so this run " + "continues to completion. The agent loop runs inside the claude CLI " + "subprocess and exposes no node to hold at. A tool that calls " + "interrupt is the supported way to stop a run for a human." + ) + + @traced(name="claude_transcript_capture", run_type="uipath") + async def _capture_transcript(self, session_id: str | None) -> None: + """Store the CLI's session transcript so a resumed process can restore it. + + The platform carries the state database across a suspension and nothing + else, so without this the CLI finds no session on the far side and + silently starts a fresh one. + """ + if not session_id: + return + try: + source = self._session_paths.find_transcript(session_id) + except ValueError as e: + logger.warning("Not storing a transcript: %s", e) + return + if source is None: + logger.warning( + "Session %s has no transcript on disk, so a resumed process will " + "not be able to continue it.", + session_id, + ) + return + await self._session_store.set_transcript( + TranscriptRecord( + session_id=session_id, + project_dir=source.parent.name, + content=source.read_text(encoding="utf-8"), + ) + ) + logger.debug( + "Stored the transcript for session %s (%d bytes).", + session_id, + source.stat().st_size, + ) + + @traced(name="claude_transcript_restore", run_type="uipath") + async def _restore_transcript(self, session_id: str | None) -> None: + """Put a stored transcript back before the CLI looks for its session. + + Written under the name this run's working directory encodes to, not the + one the previous process recorded, so the working directory is free to + differ between the two. + """ + if not session_id: + return + record = await self._session_store.get_transcript() + if record is None or record.session_id != session_id: + return + target = self._session_paths.transcript_path(session_id) + if target.exists(): + return + if record.project_dir != target.parent.name: + logger.warning( + "Session %s was filed under %s and is being restored under %s. " + "That is expected when the working directory moved, and a sign " + "the CLI changed its encoding when it did not.", + session_id, + record.project_dir, + target.parent.name, + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(record.content, encoding="utf-8") + logger.info( + "Restored the transcript for session %s under %s.", + session_id, + target.parent.name, + ) + + async def _open_run( + self, + input: dict[str, Any] | None, + options: UiPathStreamOptions | None, + ) -> RunContext: + """Build the per-invocation suspend channel and SDK options.""" + self._refuse_breakpoints(options) + channel = SuspendChannel() + resuming = bool(options and options.resume) + session_id = await self._session_id_for_run(resuming) + pending = await self._restore_pending(channel, input, resuming) + env = await self._build_llm_env() + self._session_paths.ensure() + await self._restore_transcript(session_id) + return RunContext( + channel=channel, + pending=pending, + resuming=resuming, + session_id=session_id, + sdk_options=self._build_sdk_options( + env={**env, **self._session_paths.env_overrides()}, + workspace=self._session_paths.workspace, + channel=channel, + resume_session_id=session_id, + ), + ) + + def _user_message_for_run( + self, input: dict[str, Any] | None, context: RunContext + ) -> str: + """The prompt that starts the turn. + + Resolving a parked call needs no new instruction: the payload reaches + the model as that call's tool result. A resume that carries no payload + yet leaves the call parked and re-suspends, so resume input must never + be validated against the entrypoint input schema. + """ + if context.pending is not None or context.resuming: + return RESUME_PROMPT + return self._get_user_message(input or {}) + + async def _on_result_message( + self, message: ResultMessage, context: RunContext + ) -> None: + """Record the session, verify continuity and surface a failed turn.""" + self._check_session_continuity(message, context) + await self._session_store.set_session_id(message.session_id) + if message.deferred_tool_use is not None: + return + if message.is_error: + self._raise_result_error(message) + + @staticmethod + def _check_session_continuity(message: ResultMessage, context: RunContext) -> None: + """Detect a session the CLI could not find and started fresh instead. + + A lost transcript is never reported by the CLI, so a resume that was + meant to deliver a parked call would otherwise look like a run that + succeeded and forgot everything. + """ + if context.session_id is None or context.sdk_options.fork_session: + return + if message.session_id == context.session_id: + return + detail = ( + f"Asked to resume Claude session '{context.session_id}' but the run " + f"answered from '{message.session_id}'. The session transcript is " + "missing from the runtime directory." + ) + if context.pending is None: + logger.warning("%s Earlier conversation history is lost.", detail) + return + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.SESSION_MISMATCH, + "Claude session was not resumed", + f"{detail} Interrupt {context.pending.interrupt_id} cannot be " + "delivered to the agent.", + UiPathErrorCategory.SYSTEM, + ) + + async def _suspended_result(self, context: RunContext) -> UiPathRuntimeResult: + """Persist the parked call and report it as an interrupt.""" + pending = context.channel.pending + if pending is None: + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.INTERRUPT_STATE_ERROR, + "Deferred call was not claimed", + "The CLI parked a tool call that the UiPath suspend hook never " + "claimed, so there is nothing to suspend on.", + UiPathErrorCategory.SYSTEM, + ) + await self._session_store.set_pending_suspend(pending) + await self._session_store.set_entrypoint( + EntrypointRecord(entrypoint=self.entrypoint or self.agent.name) + ) + await self._capture_transcript(await self._session_store.get_session_id()) + logger.info( + "Suspended on interrupt %s from %s.", + pending.interrupt_id, + pending.tool_name, + ) + return UiPathRuntimeResult( + output={pending.interrupt_id: pending.value}, + status=UiPathRuntimeStatus.SUSPENDED, + ) + + def _refuse_ignored_deferral(self, context: RunContext) -> None: + """Fail a run whose suspension was silently dropped by the CLI. + + The hook asked for a deferral and the turn came back without one, so + the parked call ran for real. Reporting success here would mean a job + that skipped its human step and told nobody. The known trigger is the + non-streaming fallback, which ``_SUSPEND_SAFETY_ENV`` disables, so + reaching this is either that flag being overridden or a change in CLI + behaviour. + """ + if context.channel.deferrals_requested == 0: + return + raise UiPathClaudeSDKRuntimeError( + UiPathClaudeSDKErrorCode.SUSPEND_IGNORED, + "Suspension was ignored", + "The agent asked to suspend but the run finished without " + "suspending, so the tool executed instead of parking.", + UiPathErrorCategory.SYSTEM, + ) + + async def _clear_resolved_pending(self, context: RunContext) -> None: + """Drop the stored record once the parked call has really run.""" + if context.pending is None: + return + if context.channel.pending is not None: + logger.warning( + "Interrupt %s was resumed but its tool never ran, so the pending " + "record is kept for another resume.", + context.pending.interrupt_id, + ) + return + await self._session_store.clear_pending_suspend() + + # --- Message mapping --------------------------------------------------- + + @staticmethod + def _map_assistant(message: AssistantMessage) -> list[UiPathRuntimeStateEvent]: + events: list[UiPathRuntimeStateEvent] = [] + for block in message.content: + if isinstance(block, TextBlock): + events.append( + UiPathRuntimeStateEvent( + node_name="assistant", + payload={"text": block.text}, + ) + ) + elif isinstance(block, ToolUseBlock): + events.append( + UiPathRuntimeStateEvent( + node_name="tool_call", + payload={"tool": block.name, "input": block.input}, + ) + ) + elif isinstance(block, ThinkingBlock): + events.append( + UiPathRuntimeStateEvent( + node_name="thinking", + payload={"thinking": block.thinking}, + ) + ) + else: + logger.warning( + "Unhandled assistant block type: %s", type(block).__name__ + ) + return events + + @staticmethod + def _map_tool_results(message: UserMessage) -> list[UiPathRuntimeStateEvent]: + if isinstance(message.content, list): + return [ + UiPathRuntimeStateEvent( + node_name="tool_result", + payload={ + "tool_use_id": block.tool_use_id, + "content": str(block.content), + }, + ) + for block in message.content + if isinstance(block, ToolResultBlock) + ] + return [ + UiPathRuntimeStateEvent( + node_name="tool_result", + payload={"content": str(message.content)}, + ) + ] + + @staticmethod + def _map_system(message: SystemMessage) -> UiPathRuntimeStateEvent | None: + if isinstance(message, (TaskStartedMessage, TaskProgressMessage)): + return UiPathRuntimeStateEvent( + node_name="task", + payload={ + "task_id": message.task_id, + "description": message.description, + }, + ) + if isinstance(message, TaskNotificationMessage): + return UiPathRuntimeStateEvent( + node_name="task", + payload={ + "task_id": message.task_id, + "status": message.status, + "summary": message.summary, + }, + ) + if isinstance(message, TaskUpdatedMessage): + return UiPathRuntimeStateEvent( + node_name="task", + payload={ + "task_id": message.task_id, + "status": message.status, + "patch": message.patch, + }, + ) + return None + + @staticmethod + def _update_background_tasks(message: Message, active_tasks: set[str]) -> None: + """Update the bounded background-task ledger from one SDK message.""" + if isinstance(message, TaskStartedMessage): + if message.task_type in _WAITED_BACKGROUND_TASK_TYPES: + active_tasks.add(message.task_id) + elif isinstance(message, TaskNotificationMessage): + active_tasks.discard(message.task_id) + elif isinstance(message, TaskUpdatedMessage): + if message.status in TERMINAL_TASK_STATUSES: + active_tasks.discard(message.task_id) + + async def _receive_run_messages( + self, client: ClaudeSDKClient + ) -> AsyncGenerator[Message, None]: + """Receive through the final result after bounded background work. + + ``ClaudeSDKClient.receive_response()`` stops at the first + ``ResultMessage``. For a background subagent that result ends only the + spawning turn: the task later wakes the parent and produces another + result. It is called once per turn, rather than reading the underlying + stream directly, because it is the method the tracing instrumentor + wraps: a turn read any other way produces no agent span and no tool + spans. Both methods draw on the same stream, so calling it again picks + up where the last turn stopped. + + A deferred tool use is a real run boundary and must be returned to the + UiPath resumable runtime immediately. It is therefore not held open by + this local-task ledger. + """ + active_tasks: set[str] = set() + while True: + result: ResultMessage | None = None + async for message in client.receive_response(): + self._update_background_tasks(message, active_tasks) + yield message + if isinstance(message, ResultMessage): + result = message + + if result is None: + break + if result.deferred_tool_use is not None or not active_tasks: + return + logger.info( + "Claude turn ended with %d background task(s) still running; " + "waiting for their follow-up turn.", + len(active_tasks), + ) + + if active_tasks: + task_ids = ", ".join(sorted(active_tasks)) + raise RuntimeError( + "Claude's message stream ended while background tasks were still " + f"running: {task_ids}" + ) + + @staticmethod + def _raise_result_error(message: ResultMessage) -> None: + parts = [message.result or "Agent run failed"] + if message.subtype: + parts.append(f"subtype={message.subtype}") + if message.stop_reason: + parts.append(f"stop_reason={message.stop_reason}") + if message.errors: + parts.append(f"errors={message.errors}") + if message.permission_denials: + parts.append(f"permission_denials={message.permission_denials}") + raise RuntimeError(" | ".join(parts)) + + # --- Runtime protocol --------------------------------------------------- + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream agent execution events. + + Yields: + UiPathRuntimeStateEvent for each intermediate step, then + UiPathRuntimeResult as the final event: SUCCESSFUL with the mapped + output, or SUSPENDED with ``{interrupt_id: suspend_value}`` when the + model parked a UiPath interrupt call. + """ + try: + context = await self._open_run(input, options) + user_message = self._user_message_for_run(input, context) + logger.info("Claude SDK agent workspace: %s", self._session_paths.workspace) + + result_message: ResultMessage | None = None + try: + with active_channel(context.channel): + async with ClaudeSDKClient(options=context.sdk_options) as client: + await client.query(user_message) + async for message in self._receive_run_messages(client): + self._adopt_span_parent(context.channel) + if isinstance(message, AssistantMessage): + for event in self._map_assistant(message): + yield event + elif isinstance(message, UserMessage): + for event in self._map_tool_results(message): + yield event + elif isinstance(message, ResultMessage): + await self._on_result_message(message, context) + result_message = message + elif isinstance(message, SystemMessage): + mapped = self._map_system(message) + if mapped is not None: + yield mapped + finally: + await self._stop_llm_gateway() + self._flush_gateway_spans() + + if result_message is not None and result_message.deferred_tool_use: + yield await self._suspended_result(context) + return + + self._refuse_ignored_deferral(context) + await self._clear_resolved_pending(context) + output = self._map_result_output(result_message) if result_message else {} + yield UiPathRuntimeResult( + output=output, status=UiPathRuntimeStatus.SUCCESSFUL + ) + except Exception as e: + raise self.create_runtime_error(e) from e + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the agent and return the final result.""" + stream_options = ( + UiPathStreamOptions(**options.model_dump()) if options else None + ) + result: UiPathRuntimeResult | None = None + async for event in self.stream(input, stream_options): + if isinstance(event, UiPathRuntimeResult): + result = event + return result or UiPathRuntimeResult(output={}) + + async def get_schema(self) -> UiPathRuntimeSchema: + """Get schema for this Claude SDK agent runtime.""" + entrypoints_schema = get_entrypoints_schema(self.agent) + + return UiPathRuntimeSchema( + filePath=self.entrypoint or "", + uniqueId=str(uuid4()), + type="agent", + input=entrypoints_schema.get("input", {}), + output=entrypoints_schema.get("output", {}), + graph=get_agent_graph(self.agent), + ) + + async def dispose(self) -> None: + """Cleanup runtime resources.""" + await self._stop_llm_gateway() diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/schema.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/schema.py new file mode 100644 index 00000000..068634dc --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/schema.py @@ -0,0 +1,237 @@ +"""Schema extraction utilities for ClaudeAgent definitions.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from uipath.runtime.schema import ( + UiPathRuntimeEdge, + UiPathRuntimeGraph, + UiPathRuntimeNode, + transform_nullable_types, + transform_references, +) + +from ..agent import ClaudeAgent + + +def _extract_schema_from_model(model_type: type[BaseModel]) -> dict[str, Any] | None: + """Extract a JSON schema from a Pydantic model, resolving $refs and nullable types.""" + try: + adapter: TypeAdapter[Any] = TypeAdapter(model_type) + raw_schema = adapter.json_schema() + unpacked, _ = transform_references(raw_schema) + + result: dict[str, Any] = { + "type": "object", + "properties": transform_nullable_types(unpacked.get("properties", {})), + "required": unpacked.get("required", []), + } + + if "title" in unpacked: + result["title"] = unpacked["title"] + if "description" in unpacked: + result["description"] = unpacked["description"] + + return result + except Exception: + return None + + +def _default_input_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "input": { + "type": "string", + "title": "Input", + "description": "User message for the agent", + } + }, + "required": ["input"], + } + + +def _default_output_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "result": { + "type": "string", + "title": "Result", + "description": "Final agent response", + } + }, + "required": ["result"], + } + + +def _conversation_message_item_schema() -> dict[str, Any]: + """Minimal message schema: role and contentParts required, contentParts items only need data.inline.""" + return { + "type": "object", + "properties": { + "role": {"type": "string"}, + "contentParts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mimeType": {"type": "string"}, + "data": { + "type": "object", + "properties": { + "inline": {}, + }, + "required": ["inline"], + }, + }, + "required": ["data"], + }, + }, + }, + "required": ["role", "contentParts"], + } + + +def default_messages_schema() -> dict[str, Any]: + """Default messages schema using UiPath conversation message format. + + Nothing is required. A conversation is fed by its host rather than typed + into a form, and an exchange reports its reply over the chat bridge rather + than in the run's output, so requiring ``messages`` on either side would + reject an empty payload the runtime handles and promise an output the + runtime never returns. + """ + return { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": _conversation_message_item_schema(), + "title": "Messages", + "description": "UiPath conversation messages", + } + }, + "required": [], + } + + +def _output_schema_from_options(agent: ClaudeAgent) -> dict[str, Any] | None: + """Derive the output schema from the SDK-native options.output_format.""" + output_format = agent.options.output_format + if ( + isinstance(output_format, dict) + and output_format.get("type") == "json_schema" + and isinstance(output_format.get("schema"), dict) + ): + return output_format["schema"] + return None + + +def get_entrypoints_schema( + agent: ClaudeAgent, *, conversational: bool = False +) -> dict[str, Any]: + """Extract input/output schema from a ClaudeAgent. + + Conversational agents always use the UiPath conversation message format. + Standard agents use the declared input/output Pydantic models when set, + then the SDK-native ``options.output_format`` json_schema for output, + falling back to a generic string input/result. + """ + if conversational: + return { + "input": default_messages_schema(), + "output": default_messages_schema(), + } + + input_schema = ( + _extract_schema_from_model(agent.input_schema) if agent.input_schema else None + ) + output_schema = ( + _extract_schema_from_model(agent.output_schema) if agent.output_schema else None + ) or _output_schema_from_options(agent) + + return { + "input": input_schema or _default_input_schema(), + "output": output_schema or _default_output_schema(), + } + + +def get_agent_graph(agent: ClaudeAgent) -> UiPathRuntimeGraph: + """Build a visualization graph from a ClaudeAgent. + + Claude SDK agents are represented as a single model node. MCP servers are + aggregated into a single tools node with metadata. + """ + agent_name = agent.name or "agent" + model = agent.uipath_llm.model if agent.uipath_llm else agent.options.model + + nodes: list[UiPathRuntimeNode] = [ + UiPathRuntimeNode( + id="__start__", + name="__start__", + type="__start__", + subgraph=None, + metadata=None, + ), + UiPathRuntimeNode( + id=agent_name, + name=agent_name, + type="model" if model else "node", + subgraph=None, + metadata={"model_name": model} if model else None, + ), + ] + edges: list[UiPathRuntimeEdge] = [ + UiPathRuntimeEdge(source="__start__", target=agent_name, label="input"), + ] + + mcp_servers = agent.options.mcp_servers + server_names = list(mcp_servers.keys()) if isinstance(mcp_servers, dict) else [] + builtin_tools = agent.options.tools if isinstance(agent.options.tools, list) else [] + tool_names = [*server_names, *builtin_tools] + + if tool_names: + tools_node_id = f"{agent_name}_tools" + nodes.append( + UiPathRuntimeNode( + id=tools_node_id, + name="tools", + type="tool", + subgraph=None, + metadata={ + "tool_names": tool_names, + "tool_count": len(tool_names), + "mcp_servers": server_names, + "builtin_tools": builtin_tools, + }, + ) + ) + edges.append( + UiPathRuntimeEdge(source=agent_name, target=tools_node_id, label=None) + ) + edges.append( + UiPathRuntimeEdge(source=tools_node_id, target=agent_name, label=None) + ) + + nodes.append( + UiPathRuntimeNode( + id="__end__", + name="__end__", + type="__end__", + subgraph=None, + metadata=None, + ) + ) + edges.append(UiPathRuntimeEdge(source=agent_name, target="__end__", label="output")) + + return UiPathRuntimeGraph(nodes=nodes, edges=edges) + + +__all__ = [ + "get_entrypoints_schema", + "get_agent_graph", + "default_messages_schema", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_paths.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_paths.py new file mode 100644 index 00000000..15748018 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_paths.py @@ -0,0 +1,164 @@ +"""Filesystem locations that let a Claude CLI session survive a suspension. + +The Claude CLI keeps its own session transcript at +``$CLAUDE_CONFIG_DIR/projects//.jsonl``. That middle +component is the working directory's whole absolute path with every character +outside ``[A-Za-z0-9]`` replaced by a dash, so the CLI finds a session again +only when the working directory string is byte for byte what it was. + +That string used to have to be stable, and getting it wrong cost a deployed +job: it suspended under ``/tmp/home/.job-data/42d7392e-.../__uipath`` and +resumed under ``/tmp/home/.job-data/bad7abe7-.../__uipath``, because the +platform carries the state database across and not the directory holding it. +The working directory encoded to a different name and the CLI silently started +a fresh session. + +It no longer has to be stable. The transcript is restored under the name +:func:`encode_project_dir` derives from whatever working directory this process +is using, so a run whose working directory moved still finds its session. + +That frees the working directory to be useful rather than merely constant, so +it sits at ``/.runs/``: beneath the directory the agent +was loaded from, which is where its packaged files are. The CLI searches +upwards for ``.claude``, so skills and project settings that shipped with the +agent are found with nothing copied, while each run still writes into its own +directory rather than into the package. + +A transcript must never exist in two project directories at once: the CLI then +reports the session as not found. Restore it under one name rather than copying +it around. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path + +_DEFAULT_RUNTIME_DIR = "__uipath" +_CONFIG_DIR_NAME = "claude_home" +_PROJECTS_DIR_NAME = "projects" +_RUNS_DIR_NAME = ".runs" +_FALLBACK_COMPONENT = "default" +_NON_ALNUM = re.compile(r"[^A-Za-z0-9]") + +CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR" + + +@dataclass(frozen=True) +class ClaudeSessionPaths: + """Directories for one Claude SDK session. + + Attributes: + config_dir: Value for ``CLAUDE_CONFIG_DIR``. Holds the CLI's own + session transcripts. Its own path may move between processes: only + the ``projects`` subdirectory name has to be reproduced, and that + is derived from the workspace rather than from here. + workspace: Working directory handed to the SDK subprocess as ``cwd``. + The one path that must be identical across a suspension. + """ + + config_dir: Path + workspace: Path + + @classmethod + def for_runtime( + cls, + runtime_dir: str | Path | None, + runtime_id: str, + project_dir: str | Path | None = None, + ) -> ClaudeSessionPaths: + """Derive the session paths for a runtime instance. + + Args: + runtime_dir: The persisted runtime directory. Falls back to + ``__uipath`` when unset, matching the storage path precedence. + runtime_id: Identifier the workspace is keyed on. The job key on + the platform, and the conversation id for conversational runs + so every exchange of one conversation shares a workspace. + project_dir: Directory the agent was loaded from, which is also + where its packaged files live. The working directory is placed + beneath it so the CLI, which searches upwards for ``.claude``, + finds the skills and settings that shipped with the agent + without anything being copied. Falls back to the runtime + directory when the caller cannot say. + + Returns: + Absolute paths, so they stay valid inside the SDK subprocess whose + own working directory is the workspace. + """ + base = Path(os.path.abspath(runtime_dir or _DEFAULT_RUNTIME_DIR)) + root = Path(os.path.abspath(project_dir)) if project_dir else base + return cls( + config_dir=base / _CONFIG_DIR_NAME, + workspace=(root / _RUNS_DIR_NAME / sanitize_path_component(runtime_id)), + ) + + @property + def projects_dir(self) -> Path: + """Where the CLI keeps one directory per working directory.""" + return self.config_dir / _PROJECTS_DIR_NAME + + @property + def project_dir_name(self) -> str: + """The directory name the CLI files this workspace's sessions under.""" + return encode_project_dir(self.workspace) + + def find_transcript(self, session_id: str) -> Path | None: + """Locate a session's transcript, wherever the CLI filed it.""" + if not self.projects_dir.is_dir(): + return None + matches = sorted(self.projects_dir.glob(f"*/{session_id}.jsonl")) + if not matches: + return None + if len(matches) > 1: + found = ", ".join(str(m.parent.name) for m in matches) + raise ValueError( + f"Session {session_id} has a transcript in more than one project " + f"directory ({found}), which the CLI reports as a missing session." + ) + return matches[0] + + def transcript_path(self, session_id: str) -> Path: + """Where this workspace's transcript for a session belongs.""" + return self.projects_dir / self.project_dir_name / f"{session_id}.jsonl" + + def ensure(self) -> None: + """Create both directories. Idempotent, safe to call on every run.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + self.workspace.mkdir(parents=True, exist_ok=True) + + def env_overrides(self) -> dict[str, str]: + """Environment variables pointing the CLI at the config directory.""" + return {CLAUDE_CONFIG_DIR_ENV_VAR: str(self.config_dir)} + + +def encode_project_dir(workspace: Path) -> str: + """Name the CLI files a working directory's sessions under. + + Every character outside ``[A-Za-z0-9]`` becomes a dash, so + ``/tmp/uipath-claude-workspaces/job-1`` becomes + ``-tmp-uipath-claude-workspaces-job-1``. Verified against a directory the + CLI wrote, and pinned by the defer contract, which resumes a real session + under a *different* working directory: get this wrong and the CLI reports + the session as missing rather than failing quietly here. + + Deriving the name means the working directory is free to move between + processes, which is what lets the agent run in a managed workspace. + """ + return _NON_ALNUM.sub("-", str(workspace)) + + +def sanitize_path_component(value: str) -> str: + """Reduce an identifier to a safe single path component.""" + sanitized = "".join(c if c.isalnum() or c in "-_" else "_" for c in value) + return sanitized or _FALLBACK_COMPONENT + + +__all__ = [ + "CLAUDE_CONFIG_DIR_ENV_VAR", + "ClaudeSessionPaths", + "encode_project_dir", + "sanitize_path_component", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_store.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_store.py new file mode 100644 index 00000000..be0586b6 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/session_store.py @@ -0,0 +1,226 @@ +"""Persistence of Claude SDK session state across exchanges and suspensions.""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass +from typing import Any + +from uipath.core.serialization import serialize_object +from uipath.runtime import UiPathRuntimeStorageProtocol + +from ..interrupts import INTERRUPT_SUSPEND_MODELS, PendingSuspend + +logger = logging.getLogger(__name__) + +_NAMESPACE = "claude_sdk" +_SESSION_KEY = "session_id" +_PENDING_SUSPEND_KEY = "pending_suspend" +_ENTRYPOINT_KEY = "entrypoint" +_TRANSCRIPT_KEY = "transcript" + + +@dataclass(frozen=True) +class TranscriptRecord: + """The Claude CLI session transcript, carried across a suspension. + + A resumed job is handed a fresh runtime directory, so the CLI's own copy is + gone and it would start a new session rather than continue the parked one. + It is stored here because the state database is the one thing the platform + does carry across. + + Attributes: + session_id: Session the transcript belongs to. + project_dir: Name of the directory the CLI filed it under, recorded + rather than derived. That name is the working directory encoded by + the CLI, and reimplementing the encoding here would be a second + copy to drift. + content: The transcript itself, JSON lines. + """ + + session_id: str + project_dir: str + content: str + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> TranscriptRecord | None: + """Rebuild a record, or None when nothing usable was stored.""" + if raw is None: + return None + if not isinstance(raw, dict): + logger.warning("Ignoring a transcript record that is not an object.") + return None + try: + return cls( + session_id=str(raw["session_id"]), + project_dir=str(raw["project_dir"]), + content=str(raw["content"]), + ) + except KeyError as e: + logger.warning("Ignoring an incomplete transcript record: %s", e) + return None + + +@dataclass(frozen=True) +class EntrypointRecord: + """The entrypoint a suspended run started on. + + A resumed process refuses to continue a session that was started on a + different entrypoint. + + Attributes: + entrypoint: Agent name the run started on. + """ + + entrypoint: str + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> EntrypointRecord | None: + """Rebuild a record, or None when nothing usable was stored.""" + if raw is None: + return None + if not isinstance(raw, dict) or "entrypoint" not in raw: + logger.warning("Ignoring an unusable entrypoint record.") + return None + return cls(entrypoint=str(raw["entrypoint"])) + + +def _value_type_name(value: Any) -> str: + """Name the type a resumed process has to rebuild. + + A list value is sibling triggers for one interrupt, so what matters is the + element type, not that the container was a list. + """ + if isinstance(value, list) and value: + return type(value[0]).__name__ + return type(value).__name__ + + +def _pending_to_stored(pending: PendingSuspend) -> dict[str, Any]: + """Flatten a pending record into JSON the state database can hold. + + The suspend value is whatever the interrupt tool produced, often a pydantic + model, so it is serialized here. Its type name travels alongside because + serialization erases the type the trigger creator dispatches on. + """ + return { + "interrupt_id": pending.interrupt_id, + "tool_use_id": pending.tool_use_id, + "tool_name": pending.tool_name, + "value_type": _value_type_name(pending.value), + "value": serialize_object(pending.value), + "requested_at": pending.requested_at, + } + + +def _rebuild_value(stored_type: Any, value: Any, interrupt_id: str) -> Any: + """Restore the suspend value's type from the name stored beside it. + + ``UiPathResumeTriggerCreator`` picks the resume trigger from the value's + type alone, and its default for anything unrecognised is an API trigger. + A run that re-parks after being resumed would otherwise mint an API trigger + where it first minted a task or a job trigger, and then wait forever on a + resume nobody sends. + """ + if not isinstance(stored_type, str) or stored_type == _value_type_name(value): + return value + model = INTERRUPT_SUSPEND_MODELS.get(stored_type) + if model is None: + logger.warning( + "Interrupt %s was suspended with a %s value, which is not a known " + "interrupt model, so it is reloaded as stored.", + interrupt_id, + stored_type, + ) + return value + if isinstance(value, list): + return [model.model_validate(item) for item in value] + return model.model_validate(value) + + +def _pending_from_stored(raw: Any) -> PendingSuspend | None: + """Rebuild a pending record, or None when nothing usable was stored.""" + if raw is None: + return None + if not isinstance(raw, dict): + logger.warning("Ignoring a pending suspend record that is not an object.") + return None + try: + return PendingSuspend( + interrupt_id=str(raw["interrupt_id"]), + tool_name=str(raw["tool_name"]), + value=_rebuild_value( + raw.get("value_type"), raw["value"], str(raw["interrupt_id"]) + ), + tool_use_id=None if raw["tool_use_id"] is None else str(raw["tool_use_id"]), + requested_at=str(raw["requested_at"]), + ) + except KeyError as e: + logger.warning("Ignoring an incomplete pending suspend record: %s", e) + return None + + +class ClaudeSessionStore: + """Stores Claude SDK session state keyed by runtime id. + + Holds the SDK session id, so the next exchange or a resumed process + re-attaches via ``ClaudeAgentOptions(resume=session_id)``, the pending + deferred tool call a suspension is waiting on, and the entrypoint the run + started on. Everything lands in the runtime key-value table of the state + database, which lives inside the persisted runtime directory and is + therefore remounted when a suspended job resumes. + """ + + def __init__(self, storage: UiPathRuntimeStorageProtocol, runtime_id: str) -> None: + self._storage = storage + self._runtime_id = runtime_id + + async def _get(self, key: str) -> Any: + return await self._storage.get_value(self._runtime_id, _NAMESPACE, key) + + async def _set(self, key: str, value: Any) -> None: + await self._storage.set_value(self._runtime_id, _NAMESPACE, key, value) + + async def get_session_id(self) -> str | None: + value = await self._get(_SESSION_KEY) + return value if isinstance(value, str) else None + + async def set_session_id(self, session_id: str) -> None: + await self._set(_SESSION_KEY, session_id) + + async def get_pending_suspend(self) -> PendingSuspend | None: + return _pending_from_stored(await self._get(_PENDING_SUSPEND_KEY)) + + async def set_pending_suspend(self, pending: PendingSuspend) -> None: + await self._set(_PENDING_SUSPEND_KEY, _pending_to_stored(pending)) + + async def clear_pending_suspend(self) -> None: + await self._set(_PENDING_SUSPEND_KEY, None) + + async def get_entrypoint(self) -> EntrypointRecord | None: + return EntrypointRecord.from_stored(await self._get(_ENTRYPOINT_KEY)) + + async def set_entrypoint(self, record: EntrypointRecord) -> None: + await self._set(_ENTRYPOINT_KEY, record.to_stored()) + + async def get_transcript(self) -> TranscriptRecord | None: + return TranscriptRecord.from_stored(await self._get(_TRANSCRIPT_KEY)) + + async def set_transcript(self, record: TranscriptRecord) -> None: + await self._set(_TRANSCRIPT_KEY, record.to_stored()) + + async def clear_transcript(self) -> None: + await self._set(_TRANSCRIPT_KEY, None) + + +__all__ = [ + "ClaudeSessionStore", + "EntrypointRecord", + "TranscriptRecord", +] diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/storage.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/storage.py new file mode 100644 index 00000000..a16fe04b --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/storage.py @@ -0,0 +1,110 @@ +"""SQLite storage adapter implementing UiPathResumableStorageProtocol.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import aiosqlite +from uipath.core.triggers import UiPathResumeTrigger + + +class SqliteResumableStorage: + """SQLite-backed storage for resume triggers and key-value pairs.""" + + def __init__(self, db_path: str) -> None: + self._db_path = db_path + self._db: aiosqlite.Connection | None = None + + async def _ensure_db(self) -> aiosqlite.Connection: + if self._db is None: + Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(self._db_path) + + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.execute("PRAGMA busy_timeout=30000") + await self._db.execute("PRAGMA synchronous=NORMAL") + + await self._db.execute( + "CREATE TABLE IF NOT EXISTS __uipath_resume_triggers " + "(runtime_id TEXT, data TEXT, " + "timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)" + ) + await self._db.execute( + "CREATE TABLE IF NOT EXISTS __uipath_runtime_kv " + "(runtime_id TEXT, namespace TEXT, key TEXT, value TEXT, " + "timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, " + "PRIMARY KEY (runtime_id, namespace, key))" + ) + await self._db.commit() + return self._db + + async def save_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + db = await self._ensure_db() + await db.execute( + "DELETE FROM __uipath_resume_triggers WHERE runtime_id = ?", + (runtime_id,), + ) + for trigger in triggers: + await db.execute( + "INSERT INTO __uipath_resume_triggers (runtime_id, data) VALUES (?, ?)", + (runtime_id, trigger.model_dump_json()), + ) + await db.commit() + + async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | None: + db = await self._ensure_db() + cursor = await db.execute( + "SELECT data FROM __uipath_resume_triggers WHERE runtime_id = ?", + (runtime_id,), + ) + rows = await cursor.fetchall() + if not rows: + return None + return [UiPathResumeTrigger.model_validate_json(row[0]) for row in rows] + + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + db = await self._ensure_db() + for trigger in triggers: + await db.execute( + "DELETE FROM __uipath_resume_triggers " + "WHERE runtime_id = ? AND data = ?", + (runtime_id, trigger.model_dump_json()), + ) + await db.commit() + + async def delete_trigger( + self, runtime_id: str, trigger: UiPathResumeTrigger + ) -> None: + await self.delete_triggers(runtime_id, [trigger]) + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: Any + ) -> None: + db = await self._ensure_db() + await db.execute( + "INSERT OR REPLACE INTO __uipath_runtime_kv " + "(runtime_id, namespace, key, value) VALUES (?, ?, ?, ?)", + (runtime_id, namespace, key, json.dumps(value)), + ) + await db.commit() + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + db = await self._ensure_db() + cursor = await db.execute( + "SELECT value FROM __uipath_runtime_kv " + "WHERE runtime_id = ? AND namespace = ? AND key = ?", + (runtime_id, namespace, key), + ) + row = await cursor.fetchone() + return json.loads(row[0]) if row else None + + async def dispose(self) -> None: + if self._db: + await self._db.close() + self._db = None diff --git a/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/suspend.py b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/suspend.py new file mode 100644 index 00000000..d2f37623 --- /dev/null +++ b/packages/uipath-claude-sdk/src/uipath_claude_sdk/runtime/suspend.py @@ -0,0 +1,123 @@ +"""PreToolUse hook that runs a UiPath tool's body and parks it when it suspends. + +The Claude Code CLI parks a tool call without running it when a ``PreToolUse`` +hook returns ``permissionDecision: "defer"``, writes a marker into the session +transcript, and ends the turn with ``ResultMessage.deferred_tool_use`` set. A +handler cannot pause across that boundary and the defer fires before the handler +runs, so the body is executed here instead and the handler returns what this +hook left behind. + +Hooks cannot be persisted. Every process that runs or resumes a session rebuilds +them with :func:`build_suspend_hooks`, and only when the agent registered a +server built by :func:`~uipath_claude_sdk.interrupts.uipath_tool_server`. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from claude_agent_sdk import HookContext, HookInput, HookJSONOutput, HookMatcher +from claude_agent_sdk.types import PreToolUseHookSpecificOutput + +from ..interrupts import ( + SuspendAlreadyPendingError, + SuspendChannel, + ToolIndex, + call_key, + run_tool_body, +) + +__all__ = ["TOOL_BODY_TIMEOUT_SECONDS", "build_suspend_hooks"] + +logger = logging.getLogger(__name__) + +TOOL_BODY_TIMEOUT_SECONDS = 600.0 +"""Hook timeout in seconds, forwarded verbatim to the CLI. + +The developer's whole tool body runs inside this hook, so the SDK's 60s default +would cap every UiPath tool at a minute of real work. +""" + +_IN_FLIGHT_REASON = ( + "A UiPath interrupt is already in flight for this run. Only one can be " + "pending at a time, and a tool that suspends must be the last tool call in " + "its turn. Wait for the pending one to resolve, then re-issue this call on " + "its own." +) + +_UNCORRELATABLE_REASON = ( + "This tool call carries no tool_use id, so it cannot be correlated across a " + "suspension. Re-issue the call on its own." +) + + +def _decision( + decision: Literal["allow", "deny", "defer"], reason: str | None = None +) -> HookJSONOutput: + specific: PreToolUseHookSpecificOutput = { + "hookEventName": "PreToolUse", + "permissionDecision": decision, + } + if reason is not None: + specific["permissionDecisionReason"] = reason + return {"hookSpecificOutput": specific} + + +def build_suspend_hooks( + channel: SuspendChannel, tool_index: ToolIndex +) -> dict[str, list[HookMatcher]]: + """Build the ``PreToolUse`` hooks that execute and gate the UiPath tools. + + Args: + channel: The run's suspend channel, shared with the runtime. + tool_index: Model-visible tool name to the developer's original handler, + as :func:`~uipath_claude_sdk.interrupts.uipath_tool_index` built it. + + Returns: + A mapping to merge into ``ClaudeAgentOptions.hooks``. + """ + + async def gate( + hook_input: HookInput, + tool_use_id: str | None, + context: HookContext, + ) -> HookJSONOutput: + if hook_input["hook_event_name"] != "PreToolUse": + return {} + + tool_name = hook_input["tool_name"] + binding = tool_index.get(tool_name) + if binding is None: + return {} + + pending = channel.pending + if pending is not None and pending.interrupt_id != tool_use_id: + return _decision("deny", _IN_FLIGHT_REASON) + if tool_use_id is None: + return _decision("deny", _UNCORRELATABLE_REASON) + + args = dict(hook_input["tool_input"]) + try: + outcome = await run_tool_body( + channel, binding, args, tool_name, tool_use_id + ) + except SuspendAlreadyPendingError: + return _decision("deny", _IN_FLIGHT_REASON) + + if outcome is None: + return _decision("defer") + if outcome.error is not None: + logger.warning( + "%s raised %s, which the model sees as a tool error.", + tool_name, + type(outcome.error).__name__, + ) + channel.stash(call_key(binding, args), outcome) + return _decision("allow") + + return { + "PreToolUse": [ + HookMatcher(matcher=None, hooks=[gate], timeout=TOOL_BODY_TIMEOUT_SECONDS) + ] + } diff --git a/packages/uipath-claude-sdk/template/README.md b/packages/uipath-claude-sdk/template/README.md new file mode 100644 index 00000000..68106f55 --- /dev/null +++ b/packages/uipath-claude-sdk/template/README.md @@ -0,0 +1,112 @@ +# UiPath Claude Agent SDK Template Agent + +A quickstart UiPath Claude Agent SDK agent. It answers a weather question by calling one deterministic in-process tool and returns structured output. + +> **Docs:** [Claude Agent SDK overview](https://docs.claude.com/en/api/agent-sdk/overview) **Samples:** [uipath-claude-sdk/samples](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-claude-sdk/samples) + +## What it does + +1. **Validates the input** against the `WeatherRequest` model and renders the user message from the `prompt` template +2. **Runs the Claude Agent SDK loop**, which decides when to call the tool exposed by the in-process MCP server +3. **Returns structured output** validated against the `WeatherReport` model + +### Tools + +| Tool | Description | +| ------------- | ------------------------------------------------------- | +| `get_weather` | Returns a weather reading for a city (fixed sample data) | + +The tool is defined with `@tool` and served in-process through `create_sdk_mcp_server`, so no external MCP process is needed. + +Nothing is added to the agent beyond what `main.py` declares. This scaffold has no human-in-the-loop surface, and that is by design: suspending is opt-in. To add it, write a tool that calls `interrupt()` and register it with `uipath_tool_server`, as the [simple-hitl-agent sample](https://github.com/UiPath/uipath-integrations-python/tree/main/packages/uipath-claude-sdk/samples/simple-hitl-agent) does. + +### Model + +The agent is a `UiPathClaudeAgent`, and `uipath_llm=UiPathModel("claude-sonnet-4-5")` is the explicit opt-in that routes every model call through the UiPath LLM Gateway. On this path no Anthropic API key is needed, and the UiPath credentials written by `uipath auth` (`UIPATH_URL` and `UIPATH_ACCESS_TOKEN`) are used instead. + +The id is a plain one. It is resolved against the tenant's model discovery at startup, so there is no need to know how the tenant hosts the model. To use a different model, change the id in `main.py`: + +```python +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions(...), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + ... +) +``` + +To call Anthropic directly instead, drop `uipath_llm`, set `ANTHROPIC_API_KEY`, and put the model id on the options as `ClaudeAgentOptions(model="claude-sonnet-4-5", ...)`. Nothing is injected on that path and the usage is billed to your Anthropic account. + +## Agent graph + +```mermaid +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ +``` + +## Input / Output + +`city` is the only input field the agent accepts. + +```json +// Input +{ + "city": "London" +} + +// Output +{ + "city": "London", + "temperature_celsius": 14.0, + "summary": "..." +} +``` + +## Running locally + +```bash +# Authenticate and generate entry-points.json and bindings.json +uv run uipath auth +uv run uipath init + +# Run with inline JSON +uv run uipath run agent '{"city": "London"}' + +# Or with the bundled input file +uv run uipath run agent --file input.json --output-file output.json +``` + +## Evaluation + +`evaluations/` holds one evaluation set and four evaluators. Three read the +tool calls out of the run's spans, so they check that the agent actually +consulted `get_weather` rather than answering from the model's own knowledge; +the fourth is an LLM judge over the returned `WeatherReport`. + +```bash +uv run uipath eval +``` + +The tool the agent calls is exposed through an in-process MCP server, so its +name in the spans is `mcp__weather__get_weather` — the server key and the tool +name joined by the Claude Agent SDK, not the bare `get_weather`. Criteria in +`evaluations/evaluators/` are written against that full name. + +The order evaluator is deliberately not strict: the runtime appends its own +`StructuredOutput` tool call after the agent's, to produce the typed output, +and a strict order would have to name that internal call to pass. + +The judge calls the UiPath LLM gateway, so `uipath eval` needs a tenant whose +governance policy permits the model named in +`evaluations/evaluators/llm-judge-output.json`. + +## Not yet supported + +- Tool approval, meaning routing a tool call to a human for permission +- Interactive debugging diff --git a/packages/uipath-claude-sdk/template/agent.mermaid b/packages/uipath-claude-sdk/template/agent.mermaid new file mode 100644 index 00000000..a05448ba --- /dev/null +++ b/packages/uipath-claude-sdk/template/agent.mermaid @@ -0,0 +1,11 @@ +%% AUTO-GENERATED by `uipath init`. Do not edit manually. +%% Regenerated on every `uipath init`. +flowchart TB + __start__(__start__) + agent(agent) + agent_tools(tools) + __end__(__end__) + __start__ --> |input|agent + agent --> agent_tools + agent_tools --> agent + agent --> |output|__end__ diff --git a/packages/uipath-claude-sdk/template/bindings.json b/packages/uipath-claude-sdk/template/bindings.json new file mode 100644 index 00000000..5dd5a0fd --- /dev/null +++ b/packages/uipath-claude-sdk/template/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/template/claude.json b/packages/uipath-claude-sdk/template/claude.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-claude-sdk/template/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/template/entry-points.json b/packages/uipath-claude-sdk/template/entry-points.json new file mode 100644 index 00000000..a7ef1fc5 --- /dev/null +++ b/packages/uipath-claude-sdk/template/entry-points.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", + "$id": "entry-points.json", + "entryPoints": [ + { + "filePath": "agent", + "uniqueId": "4017d52c-22ff-4cca-b381-476e3f42238f", + "type": "agent", + "input": { + "type": "object", + "properties": { + "city": { + "description": "City to report the weather for, e.g. 'London'.", + "title": "City", + "type": "string" + } + }, + "required": [ + "city" + ], + "title": "WeatherRequest" + }, + "output": { + "type": "object", + "properties": { + "city": { + "description": "City the reading refers to.", + "title": "City", + "type": "string" + }, + "temperature_celsius": { + "description": "Temperature returned by the tool.", + "title": "Temperature Celsius", + "type": "number" + }, + "summary": { + "description": "One sentence description of the weather.", + "title": "Summary", + "type": "string" + } + }, + "required": [ + "city", + "temperature_celsius", + "summary" + ], + "title": "WeatherReport" + }, + "graph": { + "nodes": [ + { + "id": "__start__", + "name": "__start__", + "type": "__start__", + "subgraph": null, + "metadata": null + }, + { + "id": "agent", + "name": "agent", + "type": "model", + "subgraph": null, + "metadata": { + "model_name": "claude-sonnet-4-5" + } + }, + { + "id": "agent_tools", + "name": "tools", + "type": "tool", + "subgraph": null, + "metadata": { + "tool_names": [ + "weather" + ], + "tool_count": 1, + "mcp_servers": [ + "weather" + ], + "builtin_tools": [] + } + }, + { + "id": "__end__", + "name": "__end__", + "type": "__end__", + "subgraph": null, + "metadata": null + } + ], + "edges": [ + { + "source": "__start__", + "target": "agent", + "label": "input" + }, + { + "source": "agent", + "target": "agent_tools", + "label": null + }, + { + "source": "agent_tools", + "target": "agent", + "label": null + }, + { + "source": "agent", + "target": "__end__", + "label": "output" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/template/evaluations/eval-sets/evaluation-set-default.json b/packages/uipath-claude-sdk/template/evaluations/eval-sets/evaluation-set-default.json new file mode 100644 index 00000000..687ced1a --- /dev/null +++ b/packages/uipath-claude-sdk/template/evaluations/eval-sets/evaluation-set-default.json @@ -0,0 +1,33 @@ +{ + "version": "1.0", + "id": "DefaultEvaluationSet", + "name": "Default Evaluation Set", + "evaluatorRefs": [ + "evaluator-llm-judge-output", + "evaluator-tool-call-order", + "evaluator-tool-call-count", + "evaluator-tool-call-arguments" + ], + "evaluations": [ + { + "id": "7d1b0c3e-2f4a-4d1b-9c2e-5a6f8b0d3e11", + "name": "Weather in Paris", + "inputs": { + "city": "Paris" + }, + "evaluationCriterias": { + "evaluator-llm-judge-output": { + "expectedOutput": "{\n \"city\": \"Paris\",\n \"temperature_celsius\": 18.0,\n \"summary\": \"It is 18°C and partly cloudy in Paris.\"\n}" + }, + "evaluator-tool-call-order": null, + "evaluator-tool-call-count": null, + "evaluator-tool-call-arguments": null + }, + "createdAt": "2026-08-08T10:00:00.000Z", + "updatedAt": "2026-08-08T10:00:00.000Z" + } + ], + "fileName": "evaluation-set-default.json", + "updatedAt": "2026-08-08T10:00:00.000Z", + "selectedEntrypoint": "agent" +} diff --git a/packages/uipath-claude-sdk/template/evaluations/evaluators/llm-judge-output.json b/packages/uipath-claude-sdk/template/evaluations/evaluators/llm-judge-output.json new file mode 100644 index 00000000..01f8b4d3 --- /dev/null +++ b/packages/uipath-claude-sdk/template/evaluations/evaluators/llm-judge-output.json @@ -0,0 +1,20 @@ +{ + "version": "1.0", + "fileName": "llm-judge-output.json", + "id": "evaluator-llm-judge-output", + "description": "Use LLM to judge semantic similarity of outputs", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "prompt": "As an expert evaluator, analyze the semantic similarity of these JSON contents to determine a score from 0-100. Focus on comparing the meaning and contextual equivalence of corresponding fields, accounting for alternative valid expressions, synonyms, and reasonable variations in language while maintaining high standards for accuracy and completeness. Provide your score with a justification, explaining briefly and concisely why you gave that score.\n----\nExpectedOutput:\n{{ExpectedOutput}}\n----\nActualOutput:\n{{ActualOutput}}", + "temperature": 0, + "maxTokens": 8000, + "targetOutputKey": "*", + "name": "LLM Judge Output", + "defaultEvaluationCriteria": { + "expectedOutput": {} + } + }, + "createdAt": "2026-08-08T10:00:00.000Z", + "updatedAt": "2026-08-08T10:00:00.000Z" +} diff --git a/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-arguments.json b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-arguments.json new file mode 100644 index 00000000..710d3a04 --- /dev/null +++ b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-arguments.json @@ -0,0 +1,24 @@ +{ + "version": "1.0", + "fileName": "tool-call-arguments.json", + "id": "evaluator-tool-call-arguments", + "description": "Verify tool call arguments match expected values", + "evaluatorTypeId": "uipath-tool-call-args", + "evaluatorConfig": { + "strict": false, + "subset": true, + "name": "Tool Call Arguments", + "defaultEvaluationCriteria": { + "toolCalls": [ + { + "name": "mcp__weather__get_weather", + "args": { + "city": "Paris" + } + } + ] + } + }, + "createdAt": "2026-08-08T10:00:00.000Z", + "updatedAt": "2026-08-08T10:00:00.000Z" +} diff --git a/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-count.json b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-count.json new file mode 100644 index 00000000..7055f127 --- /dev/null +++ b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-count.json @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "fileName": "tool-call-count.json", + "id": "evaluator-tool-call-count", + "description": "Verify tool call counts match expected values", + "evaluatorTypeId": "uipath-tool-call-count", + "evaluatorConfig": { + "strict": false, + "name": "Tool Call Count", + "defaultEvaluationCriteria": { + "toolCallsCount": { + "mcp__weather__get_weather": [ + "=", + 1 + ] + } + } + }, + "createdAt": "2026-08-08T10:00:00.000Z", + "updatedAt": "2026-08-08T10:00:00.000Z" +} diff --git a/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-order.json b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..98c8a59d --- /dev/null +++ b/packages/uipath-claude-sdk/template/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,18 @@ +{ + "version": "1.0", + "fileName": "tool-call-order.json", + "id": "evaluator-tool-call-order", + "description": "Verify tool calls occur in expected order", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "strict": false, + "name": "Tool Call Order", + "defaultEvaluationCriteria": { + "toolCallsOrder": [ + "mcp__weather__get_weather" + ] + } + }, + "createdAt": "2026-08-08T10:00:00.000Z", + "updatedAt": "2026-08-08T10:00:00.000Z" +} diff --git a/packages/uipath-claude-sdk/template/input.json b/packages/uipath-claude-sdk/template/input.json new file mode 100644 index 00000000..bb9c056e --- /dev/null +++ b/packages/uipath-claude-sdk/template/input.json @@ -0,0 +1,3 @@ +{ + "city": "London" +} diff --git a/packages/uipath-claude-sdk/template/main.py b/packages/uipath-claude-sdk/template/main.py new file mode 100644 index 00000000..ed7e9aad --- /dev/null +++ b/packages/uipath-claude-sdk/template/main.py @@ -0,0 +1,65 @@ +"""Claude Agent SDK weather agent with a custom in-process tool.""" + +import json + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +READINGS = { + "london": {"temperature_celsius": 14.0, "conditions": "overcast"}, + "paris": {"temperature_celsius": 18.0, "conditions": "partly cloudy"}, + "new york": {"temperature_celsius": 22.0, "conditions": "clear sky"}, + "tokyo": {"temperature_celsius": 26.0, "conditions": "sunny"}, + "sydney": {"temperature_celsius": 19.0, "conditions": "light rain"}, +} + + +@tool( + "get_weather", + "Get the current weather reading for a city, e.g. London.", + {"city": str}, +) +async def get_weather(args: dict) -> dict: + reading = READINGS.get(args["city"].lower().strip()) + if reading is None: + text = f"No weather reading available for {args['city']}." + else: + text = json.dumps(reading) + return {"content": [{"type": "text", "text": text}]} + + +weather_server = create_sdk_mcp_server( + name="weather", + tools=[get_weather], +) + + +class WeatherRequest(BaseModel): + city: str = Field(description="City to report the weather for, e.g. 'London'.") + + +class WeatherReport(BaseModel): + city: str = Field(description="City the reading refers to.") + temperature_celsius: float = Field(description="Temperature returned by the tool.") + summary: str = Field(description="One sentence description of the weather.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are a weather assistant. " + "Always call the get_weather tool before answering, and report only " + "the values it returns." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"weather": weather_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=WeatherRequest, + output_schema=WeatherReport, + prompt="Report the current weather in {city}.", +) diff --git a/packages/uipath-claude-sdk/template/project.uiproj b/packages/uipath-claude-sdk/template/project.uiproj new file mode 100644 index 00000000..9d28dbe0 --- /dev/null +++ b/packages/uipath-claude-sdk/template/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "uipath-claude-sdk-template-agent", + "Description": "uipath-claude-sdk-template-agent", + "MainFile": null +} \ No newline at end of file diff --git a/packages/uipath-claude-sdk/template/pyproject.toml b/packages/uipath-claude-sdk/template/pyproject.toml new file mode 100644 index 00000000..18f4670f --- /dev/null +++ b/packages/uipath-claude-sdk/template/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "uipath-claude-sdk-template-agent" +version = "0.0.1" +description = "uipath-claude-sdk-template-agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "uipath-claude-sdk>=0.1.0, <0.2.0", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev>=0.0.74", +] diff --git a/packages/uipath-claude-sdk/template/uipath.json b/packages/uipath-claude-sdk/template/uipath.json new file mode 100644 index 00000000..bd7403fd --- /dev/null +++ b/packages/uipath-claude-sdk/template/uipath.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {}, + "agents": {} +} diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/claude.json b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/claude.json new file mode 100644 index 00000000..cbfba4ad --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "src/main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/human_response.json b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/human_response.json new file mode 100644 index 00000000..c58b7e49 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/human_response.json @@ -0,0 +1,5 @@ +{ + "AgentName": "Expense approver", + "Answer": "approved", + "Comment": "Approved by finance, reimburse the full 480.00." +} diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/input.json b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/input.json new file mode 100644 index 00000000..b5124ec3 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/input.json @@ -0,0 +1,5 @@ +{ + "employee": "Dana Ionescu", + "amount": 480.0, + "category": "Conference travel to Bucharest" +} diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/pyproject.toml b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/pyproject.toml new file mode 100644 index 00000000..4c326a1a --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-action-center-hitl-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Action Center HITL Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath>=2.13.0", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/run.sh b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/run.sh new file mode 100755 index 00000000..7bebdb46 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/run.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +# Tenant prerequisite: this testcase raises a REAL Action Center action, because +# its tool passes a CreateTask to interrupt(). The tenant behind CLIENT_ID needs +# the "generic_escalation_app" action app reachable in the "Shared" folder, and +# the client needs permission to create actions there. Without it the run fails +# while creating the resume trigger ("appName or appKey is required", or an app +# lookup 404), not inside the agent. The other platform models need tenant +# resources this account cannot be assumed to own, so they are covered +# hermetically by tests/test_suspend_resume.py instead. +# +# The resume below answers the agent directly rather than completing the +# action, so every green run leaves one pending action behind in the tenant. + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Environment variables:" +echo "UIPATH_JOB_KEY: $UIPATH_JOB_KEY" + +echo "Running agent..." +echo "Input from input.json file" +uv run uipath run agent --file input.json + +echo "Resuming agent run with the approver's answer..." +uv run uipath run agent --file human_response.json --resume diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/assert.py b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/assert.py new file mode 100644 index 00000000..d093d7b2 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/assert.py @@ -0,0 +1,117 @@ +import json +import os +import sys + +print("Checking Action Center HITL agent output...") + +# Check NuGet package +uipath_dir = ".uipath" +if not os.path.exists(uipath_dir): + print("NuGet package directory (.uipath) not found") + sys.exit(1) + +nupkg_files = [f for f in os.listdir(uipath_dir) if f.endswith(".nupkg")] +if not nupkg_files: + print("NuGet package file (.nupkg) not found in .uipath directory") + sys.exit(1) + +print(f"NuGet package found: {nupkg_files[0]}") + +# Check agent output file +output_file = "__uipath/output.json" +if not os.path.isfile(output_file): + print("Agent output file not found") + sys.exit(1) + +print("Agent output file found") + +# Without the transcript a resume starts a conversation that forgot the action. +transcript_root = "__uipath/claude_home/projects" +if not os.path.isdir(transcript_root): + print(f"Claude session transcripts not found under {transcript_root}") + sys.exit(1) + +transcripts = [ + os.path.join(root, name) + for root, _, names in os.walk(transcript_root) + for name in names + if name.endswith(".jsonl") +] +if not transcripts: + print("No Claude session transcript was persisted in the runtime directory") + sys.exit(1) + +print(f"Claude session transcript found: {transcripts[0]}") + +deferred = False +created_action = False +for path in transcripts: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + if "hook_deferred_tool" in content: + deferred = True + if "mcp__approvals__request_approval" in content: + created_action = True + +if not deferred: + print("No deferred tool call in the transcript, the run never really suspended") + sys.exit(1) + +print("Deferred tool call recorded in the session transcript") + +# The whole point of this testcase: the suspension carried a CreateTask, so it +# became a real Action Center task rather than a plain API trigger. +if not created_action: + print("The agent never called mcp__approvals__request_approval") + sys.exit(1) + +print("Action Center interrupt tool call recorded in the session transcript") + +# Check status and required fields +try: + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + status = output_data.get("status") + if status != "successful": + print(f"Agent execution failed with status: {status}") + sys.exit(1) + + print("Agent execution status: successful") + + if "output" not in output_data: + print("Missing 'output' field in agent response") + sys.exit(1) + + output_content = output_data["output"] + + for field in ("approved", "approved_amount", "summary"): + if field not in output_content: + print(f"Missing '{field}' field in output") + sys.exit(1) + + if output_content["approved"] is not True: + print("The agent did not act on the approval delivered on resume") + sys.exit(1) + + approved_amount = float(output_content["approved_amount"]) + expected_amount = 480.0 + if abs(approved_amount - expected_amount) > 0.01: + print(f"Approved amount {approved_amount} is not {expected_amount}") + sys.exit(1) + + summary = output_content["summary"] + if not summary or not str(summary).strip(): + print("Summary field is empty") + sys.exit(1) + + print(f"Approved: {output_content['approved']}") + print(f"Approved amount: {approved_amount}") + print(f"Summary: {summary}") + + print("Required fields validation passed") + print("Action Center HITL agent working correctly.") + +except Exception as e: + print(f"Error checking output: {e}") + sys.exit(1) diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/main.py b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/main.py new file mode 100644 index 00000000..dc8bf3c8 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/src/main.py @@ -0,0 +1,97 @@ +"""Expense agent that raises an Action Center action before it approves anything.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field +from uipath.platform.common import CreateTask + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +ESCALATION_APP = "generic_escalation_app" +ESCALATION_FOLDER = "Shared" + +LEDGER: dict[str, float] = {} + + +@tool( + "request_approval", + "Ask a human to approve or refuse an expense, and wait for their decision.", + {"employee": str, "amount": float, "question": str}, +) +async def request_approval(args: dict[str, Any]) -> dict[str, Any]: + action_data = await interrupt( + CreateTask( + title=f"Expense approval for {args['employee']}: {args['amount']}", + data={"AgentName": "Expense approver", "AgentOutput": args["question"]}, + app_name=ESCALATION_APP, + app_folder_path=ESCALATION_FOLDER, + ) + ) + decision = { + "answer": action_data.get("Answer"), + "comment": action_data.get("Comment", ""), + } + return {"content": [{"type": "text", "text": json.dumps(decision, default=str)}]} + + +@tool( + "record_reimbursement", + "Record an approved reimbursement. Never call this before a human approved it.", + {"employee": str, "amount": float}, +) +async def record_reimbursement(args: dict[str, Any]) -> dict[str, Any]: + LEDGER[args["employee"]] = args["amount"] + return { + "content": [ + { + "type": "text", + "text": f"Recorded {args['amount']} for {args['employee']}.", + } + ] + } + + +approvals_server = uipath_tool_server("approvals", tools=[request_approval]) +expenses_server = create_sdk_mcp_server(name="expenses", tools=[record_reimbursement]) + + +class ExpenseReport(BaseModel): + employee: str = Field(description="Who filed the expense report.") + amount: float = Field(description="Amount the employee is claiming back.") + category: str = Field(description="What the expense was for.") + + +class ApprovalOutcome(BaseModel): + approved: bool = Field(description="Whether the human approved the expense.") + approved_amount: float = Field(description="Amount approved, 0 when refused.") + summary: str = Field(description="One sentence describing what happened.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You review employee expense reports. You never decide one yourself. " + "Before you record anything you MUST call request_approval, passing " + "the employee, the amount and a question naming what they are " + "claiming and why. Make that call on its own, never together with " + "another tool call. It comes back with the human's decision. Call " + "record_reimbursement only if the decision approves the report, for " + "the amount they approved, and leave it untouched otherwise." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"approvals": approvals_server, "expenses": expenses_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ExpenseReport, + output_schema=ApprovalOutcome, + prompt="Expense report from {employee}: {amount} claimed back for {category}.", +) diff --git a/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/uipath.json b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/action-center-hitl-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/.env.example b/packages/uipath-claude-sdk/testcases/chat-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/claude.json b/packages/uipath-claude-sdk/testcases/chat-agent/claude.json new file mode 100644 index 00000000..cbfba4ad --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "src/main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/input.json b/packages/uipath-claude-sdk/testcases/chat-agent/input.json new file mode 100644 index 00000000..7a28e685 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/input.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "I need a room for 6 people on Tuesday at 10:00 for the Kingfisher review. What fits?" + } + } + ] + } + ] +} diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/next_message.json b/packages/uipath-claude-sdk/testcases/chat-agent/next_message.json new file mode 100644 index 00000000..ea0d1413 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/next_message.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "Remind me which room you suggested and what the meeting was called." + } + } + ] + } + ] +} diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/pyproject.toml b/packages/uipath-claude-sdk/testcases/chat-agent/pyproject.toml new file mode 100644 index 00000000..190e94b9 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-chat-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Conversational Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath>=2.13.0", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/run.sh b/packages/uipath-claude-sdk/testcases/chat-agent/run.sh new file mode 100755 index 00000000..b439a0a2 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/run.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Environment variables:" +echo "UIPATH_JOB_KEY: $UIPATH_JOB_KEY" + +echo "Running agent..." +echo "First message from input.json file" +uv run uipath run agent --file input.json + +echo "Second message from next_message.json file" +uv run uipath run agent --file next_message.json --resume + +echo "Third message from third_message.json file" +uv run uipath run agent --file third_message.json --resume diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/src/assert.py b/packages/uipath-claude-sdk/testcases/chat-agent/src/assert.py new file mode 100644 index 00000000..3458d8d1 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/src/assert.py @@ -0,0 +1,157 @@ +import json +import os +import sys + +print("Checking conversational chat agent output...") + +# Check NuGet package +uipath_dir = ".uipath" +if not os.path.exists(uipath_dir): + print("NuGet package directory (.uipath) not found") + sys.exit(1) + +nupkg_files = [f for f in os.listdir(uipath_dir) if f.endswith(".nupkg")] +if not nupkg_files: + print("NuGet package file (.nupkg) not found in .uipath directory") + sys.exit(1) + +print(f"NuGet package found: {nupkg_files[0]}") + +# Check agent output file +output_file = "__uipath/output.json" +if not os.path.isfile(output_file): + print("Agent output file not found") + sys.exit(1) + +print("Agent output file found") + +# Every exchange ends suspended on a fresh trigger waiting for the next message, +# so a conversational run never reports success. A terminal status here means +# the runtime stopped waiting and the conversation cannot be continued. +try: + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) +except Exception as e: + print(f"Error reading output: {e}") + sys.exit(1) + +status = output_data.get("status") +if status != "suspended": + print(f"The last exchange should have suspended for the next message: {status}") + sys.exit(1) + +print("Agent execution status: suspended") + +output_content = output_data.get("output") +if not isinstance(output_content, dict) or len(output_content) != 1: + print(f"Expected exactly one resume trigger in the output, got: {output_content}") + sys.exit(1) + +print(f"Next-message trigger: {next(iter(output_content))}") + +# The conversation itself lives in the CLI's session transcript. Without it a +# later exchange starts a fresh session that has forgotten everything. +transcript_root = "__uipath/claude_home/projects" +if not os.path.isdir(transcript_root): + print(f"Claude session transcripts not found under {transcript_root}") + sys.exit(1) + +transcripts = [ + os.path.join(root, name) + for root, _, names in os.walk(transcript_root) + for name in names + if name.endswith(".jsonl") +] +if not transcripts: + print("No Claude session transcript was persisted in the runtime directory") + sys.exit(1) + +session_ids = {os.path.splitext(os.path.basename(p))[0] for p in transcripts} +if len(session_ids) != 1: + print(f"Three exchanges should share one Claude session, found: {session_ids}") + sys.exit(1) + +print(f"Single Claude session across all exchanges: {next(iter(session_ids))}") + +# A working directory that resolves through a symlink is filed under two names, +# so read the fullest copy rather than assuming there is only one. +entries: list[dict] = [] +for path in transcripts: + with open(path, "r", encoding="utf-8") as f: + lines = [json.loads(line) for line in f if line.strip()] + if len(lines) > len(entries): + entries = lines + +user_messages: list[str] = [] +assistant_texts: list[str] = [] +tool_calls: list[dict] = [] +for entry in entries: + message = entry.get("message") + if not isinstance(message, dict): + continue + content = message.get("content") + if entry.get("type") == "user" and isinstance(content, str): + user_messages.append(content) + if entry.get("type") != "assistant" or not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + assistant_texts.append(block.get("text", "")) + elif block.get("type") == "tool_use": + tool_calls.append(block) + +# The message each exchange carries has to reach the model. A resume that +# replaced it with a fixed continuation would leave "Continue." here instead, +# and the run would still look successful. +expected_messages = [] +for name in ("input.json", "next_message.json", "third_message.json"): + with open(name, "r", encoding="utf-8") as f: + payload = json.load(f) + parts = payload["messages"][-1]["contentParts"] + expected_messages.append("".join(part["data"]["inline"] for part in parts)) + +if len(user_messages) != len(expected_messages): + print(f"Expected {len(expected_messages)} user turns, found {len(user_messages)}:") + for text in user_messages: + print(f" - {text[:80]}") + sys.exit(1) + +for index, (expected, actual) in enumerate(zip(expected_messages, user_messages), 1): + if expected != actual: + print(f"Turn {index} reached the model as {actual!r}, expected {expected!r}") + sys.exit(1) + print(f"Turn {index} delivered verbatim: {actual[:60]}...") + +# The second turn asks for two facts only the first turn established, so an +# answer holding both proves the session was really resumed rather than +# restarted with the newest message alone. +recall = "\n".join(assistant_texts).lower() +for fact in ("beehive", "kingfisher"): + if fact not in recall: + print(f"The agent never recalled '{fact}', so earlier context was lost") + sys.exit(1) + +print("Agent recalled the room and the meeting name from the first turn") + +# The third turn adds two people to a headcount only the first turn gave, and +# asks for a fresh lookup, so the tool has to be called again with the sum. +lookups = [call for call in tool_calls if call.get("name", "").endswith("find_rooms")] +if len(lookups) < 2: + print(f"Expected the room list to be looked up again, got {len(lookups)} calls") + sys.exit(1) + +people = lookups[-1].get("input", {}).get("people") +try: + people = int(people) +except (TypeError, ValueError): + print(f"Last find_rooms call had no usable headcount: {lookups[-1].get('input')}") + sys.exit(1) + +if people <= 6: + print(f"Last find_rooms call used {people}, so the two extra people were lost") + sys.exit(1) + +print(f"Room list looked up again for {people} people") +print("Conversational chat agent working correctly.") diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/src/main.py b/packages/uipath-claude-sdk/testcases/chat-agent/src/main.py new file mode 100644 index 00000000..56ef658b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/src/main.py @@ -0,0 +1,57 @@ +"""Meeting-room assistant that answers one message per invocation.""" + +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +ROOMS = [ + {"name": "Aviary", "seats": 4, "floor": 1}, + {"name": "Beehive", "seats": 8, "floor": 2}, + {"name": "Cedar Hall", "seats": 20, "floor": 3}, +] + + +@tool( + "find_rooms", + "List the meeting rooms that seat at least the given number of people.", + {"people": int}, +) +async def find_rooms(args: dict[str, Any]) -> dict[str, Any]: + people = int(args["people"]) + matches = [room for room in ROOMS if int(room["seats"]) >= people] + if not matches: + return { + "content": [ + {"type": "text", "text": f"No room seats {people} people."}, + ] + } + listing = "\n".join( + f"{room['name']}: {room['seats']} seats, floor {room['floor']}" + for room in matches + ) + return {"content": [{"type": "text", "text": listing}]} + + +rooms_server = create_sdk_mcp_server(name="rooms", tools=[find_rooms]) + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You help colleagues pick a meeting room. Call find_rooms to see what " + "seats a given headcount, and suggest the smallest room that fits. " + "You can only read the room list: you cannot book, hold or cancel " + "anything, and you know nothing about who is using a room when. Say " + "so plainly when you are asked for something you cannot do. Answer in " + "one or two sentences, and use what the person already told you " + "earlier in the conversation rather than asking for it again." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"rooms": rooms_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), +) diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/third_message.json b/packages/uipath-claude-sdk/testcases/chat-agent/third_message.json new file mode 100644 index 00000000..f4f9c7c2 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/third_message.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "Two more people are joining. Look the room list up again for the new headcount and tell me whether your suggestion still holds." + } + } + ] + } + ] +} diff --git a/packages/uipath-claude-sdk/testcases/chat-agent/uipath.json b/packages/uipath-claude-sdk/testcases/chat-agent/uipath.json new file mode 100644 index 00000000..67dde2f2 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/chat-agent/uipath.json @@ -0,0 +1,5 @@ +{ + "runtimeOptions": { + "isConversational": true + } +} diff --git a/packages/uipath-claude-sdk/testcases/common/trace_assert.py b/packages/uipath-claude-sdk/testcases/common/trace_assert.py new file mode 100644 index 00000000..d91a0de6 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/common/trace_assert.py @@ -0,0 +1,88 @@ +""" +Simple trace assertion - just check that expected spans exist with required attributes. +""" +import json +from typing import Any + + +def load_traces(traces_file: str) -> list[dict[str, Any]]: + """Load traces from a JSONL file.""" + traces = [] + with open(traces_file, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + traces.append(json.loads(line)) + return traces + + +def load_expected_traces(expected_file: str) -> list[dict[str, Any]]: + """Load expected trace definitions from a JSON file.""" + with open(expected_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("required_spans", []) + + +def get_attributes(span: dict[str, Any]) -> dict[str, Any]: + """Parse attributes from a span.""" + if "attributes" in span and isinstance(span["attributes"], dict): + return span["attributes"] + attributes_str = span.get("Attributes", "{}") + try: + return json.loads(attributes_str) + except json.JSONDecodeError: + return {} + + +def matches_value(expected_value: Any, actual_value: Any) -> bool: + """Check if an actual value matches the expected value.""" + if expected_value == "*": + return True + if isinstance(expected_value, list): + return actual_value in expected_value + return expected_value == actual_value + + +def matches_expected(span: dict[str, Any], expected: dict[str, Any]) -> bool: + """Check if a span matches the expected definition.""" + expected_name = expected.get("name") + actual_name = span.get("name") or span.get("Name") + if isinstance(expected_name, list): + if actual_name not in expected_name: + return False + elif expected_name != actual_name: + return False + if "attributes" in expected: + actual_attrs = get_attributes(span) + for key, expected_value in expected["attributes"].items(): + if key not in actual_attrs: + return False + if not matches_value(expected_value, actual_attrs[key]): + return False + return True + + +def assert_traces(traces_file: str, expected_file: str) -> None: + """Assert that all expected traces exist in the traces file.""" + traces = load_traces(traces_file) + expected_spans = load_expected_traces(expected_file) + print(f"Loaded {len(traces)} traces from {traces_file}") + print(f"Checking {len(expected_spans)} expected spans...") + missing_spans = [] + for expected in expected_spans: + found = False + name = expected["name"] + name_str = name if isinstance(name, str) else f"[{' | '.join(name)}]" + for span in traces: + if matches_expected(span, expected): + found = True + print(f"✓ Found span: {name_str}") + break + if not found: + missing_spans.append(name_str) + print(f"✗ Missing span: {name_str}") + if missing_spans: + raise AssertionError( + f"Missing expected spans: {', '.join(missing_spans)}\n" + f"Expected {len(expected_spans)} spans, found {len(expected_spans) - len(missing_spans)}" + ) + print(f"\n✓ All {len(expected_spans)} expected spans found!") diff --git a/packages/uipath-claude-sdk/testcases/common/validate_output.sh b/packages/uipath-claude-sdk/testcases/common/validate_output.sh new file mode 100755 index 00000000..2c7778a6 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/common/validate_output.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Common utility to print UiPath output file +# Usage: source /app/testcases/common/validate_output.sh + +debug_print_uipath_output() { + echo "Printing output file..." + if [ -f "__uipath/output.json" ]; then + echo "=== OUTPUT FILE CONTENT ===" + cat __uipath/output.json + echo "=== END OUTPUT FILE CONTENT ===" + else + echo "ERROR: __uipath/output.json not found!" + echo "Checking directory contents:" + ls -la + if [ -d "__uipath" ]; then + echo "Contents of __uipath directory:" + ls -la __uipath/ + else + echo "__uipath directory does not exist!" + fi + fi +} + +validate_output() { + echo "Printing output file for validation..." + debug_print_uipath_output + + echo "Validating output..." + PYTHONPATH="../common:$PYTHONPATH" python src/assert.py || { echo "Validation failed!"; exit 1; } + + echo "Testcase completed successfully." +} + +validate_output diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/.env.example b/packages/uipath-claude-sdk/testcases/quickstart-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/claude.json b/packages/uipath-claude-sdk/testcases/quickstart-agent/claude.json new file mode 100644 index 00000000..cbfba4ad --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "src/main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/input.json b/packages/uipath-claude-sdk/testcases/quickstart-agent/input.json new file mode 100644 index 00000000..1676d75d --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/input.json @@ -0,0 +1,5 @@ +{ + "amount": 100, + "from_currency": "EUR", + "to_currency": "RON" +} diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/pyproject.toml b/packages/uipath-claude-sdk/testcases/quickstart-agent/pyproject.toml new file mode 100644 index 00000000..38cb7266 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-quickstart-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Quickstart Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath>=2.13.0", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/run.sh b/packages/uipath-claude-sdk/testcases/quickstart-agent/run.sh new file mode 100755 index 00000000..4e16deb6 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Running agent..." +echo "Input from input.json file" +uv run uipath run agent --file input.json diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/src/assert.py b/packages/uipath-claude-sdk/testcases/quickstart-agent/src/assert.py new file mode 100644 index 00000000..a6065a7b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/src/assert.py @@ -0,0 +1,80 @@ +import json +import os +import sys + +print("Checking quickstart agent output...") + +# Check NuGet package +uipath_dir = ".uipath" +if not os.path.exists(uipath_dir): + print("NuGet package directory (.uipath) not found") + sys.exit(1) + +nupkg_files = [f for f in os.listdir(uipath_dir) if f.endswith(".nupkg")] +if not nupkg_files: + print("NuGet package file (.nupkg) not found in .uipath directory") + sys.exit(1) + +print(f"NuGet package found: {nupkg_files[0]}") + +# Check agent output file +output_file = "__uipath/output.json" +if not os.path.isfile(output_file): + print("Agent output file not found") + sys.exit(1) + +print("Agent output file found") + +# Check status and required fields +try: + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + status = output_data.get("status") + if status != "successful": + print(f"Agent execution failed with status: {status}") + sys.exit(1) + + print("Agent execution status: successful") + + if "output" not in output_data: + print("Missing 'output' field in agent response") + sys.exit(1) + + output_content = output_data["output"] + + for field in ("converted_amount", "rate_used", "explanation"): + if field not in output_content: + print(f"Missing '{field}' field in output") + sys.exit(1) + + explanation = output_content["explanation"] + if not explanation or not str(explanation).strip(): + print("Explanation field is empty") + sys.exit(1) + + # The tool's fixed rates make 100 EUR worth 1.09 / 0.22 * 100 RON. The + # tolerance absorbs the rounding a model may apply while still failing if + # the tool was skipped and the number was invented. + expected_rate = 1.09 / 0.22 + rate_used = float(output_content["rate_used"]) + if abs(rate_used - expected_rate) > 0.02 * expected_rate: + print(f"Rate {rate_used} does not match the rate the tool returns") + sys.exit(1) + + expected_amount = 100 * expected_rate + converted_amount = float(output_content["converted_amount"]) + if abs(converted_amount - expected_amount) > 0.02 * expected_amount: + print(f"Converted amount {converted_amount} is not close to {expected_amount}") + sys.exit(1) + + print(f"Converted amount: {converted_amount}") + print(f"Rate used: {rate_used}") + print(f"Explanation: {explanation}") + + print("Required fields validation passed") + print("Quickstart agent working correctly.") + +except Exception as e: + print(f"Error checking output: {e}") + sys.exit(1) diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/src/main.py b/packages/uipath-claude-sdk/testcases/quickstart-agent/src/main.py new file mode 100644 index 00000000..4b929d3d --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/src/main.py @@ -0,0 +1,63 @@ +"""Currency conversion agent with a deterministic in-process tool.""" + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel + +RATES_TO_USD = { + "USD": 1.0, + "EUR": 1.09, + "GBP": 1.27, + "RON": 0.22, + "JPY": 0.0067, +} + + +@tool( + "get_exchange_rate", + "Get the exchange rate from one currency to another (ISO codes, e.g. EUR, USD).", + {"from_currency": str, "to_currency": str}, +) +async def get_exchange_rate(args: dict) -> dict: + source = RATES_TO_USD.get(args["from_currency"].upper()) + target = RATES_TO_USD.get(args["to_currency"].upper()) + if source is None or target is None: + return {"content": [{"type": "text", "text": "Unknown currency code."}]} + return {"content": [{"type": "text", "text": str(source / target)}]} + + +currency_server = create_sdk_mcp_server(name="currency", tools=[get_exchange_rate]) + + +class ConversionRequest(BaseModel): + amount: float = Field(description="Amount to convert.") + from_currency: str = Field(description="ISO code to convert from, e.g. 'EUR'.") + to_currency: str = Field(description="ISO code to convert to, e.g. 'RON'.") + + +class ConversionResult(BaseModel): + converted_amount: float = Field( + description="Amount expressed in the target currency." + ) + rate_used: float = Field(description="Rate the tool returned.") + explanation: str = Field(description="One sentence explaining the conversion.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You are a currency conversion assistant. " + "Always call the get_exchange_rate tool to look up the rate, and " + "compute the result from the value it returns." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"currency": currency_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=ConversionRequest, + output_schema=ConversionResult, + prompt="Convert {amount} {from_currency} to {to_currency}.", +) diff --git a/packages/uipath-claude-sdk/testcases/quickstart-agent/uipath.json b/packages/uipath-claude-sdk/testcases/quickstart-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/quickstart-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/.env.example b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/claude.json b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/claude.json new file mode 100644 index 00000000..cbfba4ad --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/claude.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "src/main.py:agent" + } +} diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/human_response.json b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/human_response.json new file mode 100644 index 00000000..99f2fef8 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/human_response.json @@ -0,0 +1,3 @@ +{ + "response": "yes, approved, go ahead and refund the full amount" +} diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/input.json b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/input.json new file mode 100644 index 00000000..3c42cfd7 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/input.json @@ -0,0 +1,5 @@ +{ + "order_id": "ORD-4711", + "amount": 249.99, + "reason": "The package arrived damaged." +} diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/pyproject.toml b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/pyproject.toml new file mode 100644 index 00000000..7c470f35 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-simple-hitl-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Simple HITL Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath>=2.13.0", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/run.sh b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/run.sh new file mode 100755 index 00000000..c6e73f7c --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Environment variables:" +echo "UIPATH_JOB_KEY: $UIPATH_JOB_KEY" + +echo "Running agent..." +echo "Input from input.json file" +uv run uipath run agent --file input.json + +echo "Resuming agent run with the human response..." +uv run uipath run agent --file human_response.json --resume diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/assert.py b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/assert.py new file mode 100644 index 00000000..67acd413 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/assert.py @@ -0,0 +1,106 @@ +import json +import os +import sys + +print("Checking simple HITL agent output...") + +# Check NuGet package +uipath_dir = ".uipath" +if not os.path.exists(uipath_dir): + print("NuGet package directory (.uipath) not found") + sys.exit(1) + +nupkg_files = [f for f in os.listdir(uipath_dir) if f.endswith(".nupkg")] +if not nupkg_files: + print("NuGet package file (.nupkg) not found in .uipath directory") + sys.exit(1) + +print(f"NuGet package found: {nupkg_files[0]}") + +# Check agent output file +output_file = "__uipath/output.json" +if not os.path.isfile(output_file): + print("Agent output file not found") + sys.exit(1) + +print("Agent output file found") + +# Without the transcript a resume starts a conversation that forgot the question. +transcript_root = "__uipath/claude_home/projects" +if not os.path.isdir(transcript_root): + print(f"Claude session transcripts not found under {transcript_root}") + sys.exit(1) + +transcripts = [ + os.path.join(root, name) + for root, _, names in os.walk(transcript_root) + for name in names + if name.endswith(".jsonl") +] +if not transcripts: + print("No Claude session transcript was persisted in the runtime directory") + sys.exit(1) + +print(f"Claude session transcript found: {transcripts[0]}") + +deferred = False +for path in transcripts: + with open(path, "r", encoding="utf-8") as f: + if "hook_deferred_tool" in f.read(): + deferred = True + break + +if not deferred: + print("No deferred tool call in the transcript, the run never really suspended") + sys.exit(1) + +print("Deferred tool call recorded in the session transcript") + +# Check status and required fields +try: + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + status = output_data.get("status") + if status != "successful": + print(f"Agent execution failed with status: {status}") + sys.exit(1) + + print("Agent execution status: successful") + + if "output" not in output_data: + print("Missing 'output' field in agent response") + sys.exit(1) + + output_content = output_data["output"] + + for field in ("approved", "refunded_amount", "summary"): + if field not in output_content: + print(f"Missing '{field}' field in output") + sys.exit(1) + + if output_content["approved"] is not True: + print("The agent did not act on the approval delivered on resume") + sys.exit(1) + + refunded_amount = float(output_content["refunded_amount"]) + expected_amount = 249.99 + if abs(refunded_amount - expected_amount) > 0.01: + print(f"Refunded amount {refunded_amount} is not {expected_amount}") + sys.exit(1) + + summary = output_content["summary"] + if not summary or not str(summary).strip(): + print("Summary field is empty") + sys.exit(1) + + print(f"Approved: {output_content['approved']}") + print(f"Refunded amount: {refunded_amount}") + print(f"Summary: {summary}") + + print("Required fields validation passed") + print("Simple HITL agent working correctly.") + +except Exception as e: + print(f"Error checking output: {e}") + sys.exit(1) diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/main.py b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/main.py new file mode 100644 index 00000000..7eaf55c0 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/src/main.py @@ -0,0 +1,80 @@ +"""Refund agent that asks a human for approval before it moves any money.""" + +import json +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, tool +from pydantic import BaseModel, Field + +from uipath_claude_sdk import ( + UiPathClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) + +LEDGER: dict[str, float] = {} + + +@tool( + "ask_approver", + "Ask a human a question and wait for their answer.", + {"question": str}, +) +async def ask_approver(args: dict[str, Any]) -> dict[str, Any]: + answer = await interrupt(args["question"]) + return {"content": [{"type": "text", "text": json.dumps(answer, default=str)}]} + + +@tool( + "issue_refund", + "Refund an order. Never call this before a human has approved the refund.", + {"order_id": str, "amount": float}, +) +async def issue_refund(args: dict[str, Any]) -> dict[str, Any]: + LEDGER[args["order_id"]] = args["amount"] + return { + "content": [ + { + "type": "text", + "text": f"Refunded {args['amount']} on order {args['order_id']}.", + } + ] + } + + +approvals_server = uipath_tool_server("approvals", tools=[ask_approver]) +refunds_server = create_sdk_mcp_server(name="refunds", tools=[issue_refund]) + + +class RefundRequest(BaseModel): + order_id: str = Field(description="Order the customer wants refunded.") + amount: float = Field(description="Amount the customer is asking back.") + reason: str = Field(description="Why the customer is asking for a refund.") + + +class RefundOutcome(BaseModel): + approved: bool = Field(description="Whether the human approved the refund.") + refunded_amount: float = Field(description="Amount refunded, 0 when refused.") + summary: str = Field(description="One sentence describing what happened.") + + +agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "You process customer refund requests. You never refund anything on " + "your own authority. Call ask_approver first, with a question naming " + "the order, the amount and the reason. Make that call on its own, " + "never together with another tool call. Call issue_refund only if the " + "answer approves the refund, and leave the order untouched otherwise." + ), + max_turns=10, + permission_mode="bypassPermissions", + tools=[], + mcp_servers={"approvals": approvals_server, "refunds": refunds_server}, + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + input_schema=RefundRequest, + output_schema=RefundOutcome, + prompt="Refund request for order {order_id}: {amount} back, because {reason}.", +) diff --git a/packages/uipath-claude-sdk/testcases/simple-hitl-agent/uipath.json b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/simple-hitl-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-claude-sdk/testcases/template-agent/.env.example b/packages/uipath-claude-sdk/testcases/template-agent/.env.example new file mode 100644 index 00000000..3e2f143b --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/template-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL=xxx +UIPATH_TENANT_ID=xxx +UIPATH_ORGANIZATION_ID=xxx +UIPATH_ACCESS_TOKEN=xxx diff --git a/packages/uipath-claude-sdk/testcases/template-agent/.gitignore b/packages/uipath-claude-sdk/testcases/template-agent/.gitignore new file mode 100644 index 00000000..29b41f1c --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/template-agent/.gitignore @@ -0,0 +1,20 @@ +# Copied from ../../template by run.sh. +main.py +input.json +uipath.json +claude.json +evaluations/ + +# Produced by the run. +eval_output.json +traces.jsonl + +# Produced by `uipath init`. +.agent/ +.claude/ +agent.mermaid +AGENTS.md +CLAUDE.md +bindings.json +entry-points.json +project.uiproj diff --git a/packages/uipath-claude-sdk/testcases/template-agent/pyproject.toml b/packages/uipath-claude-sdk/testcases/template-agent/pyproject.toml new file mode 100644 index 00000000..360fe6e9 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/template-agent/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-template-agent" +version = "0.0.1" +description = "UiPath Claude Agent SDK Template Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-claude-sdk", + "uipath>=2.13.0", +] + +[tool.uv.sources] +uipath-claude-sdk = { path = "../../", editable = true } diff --git a/packages/uipath-claude-sdk/testcases/template-agent/run.sh b/packages/uipath-claude-sdk/testcases/template-agent/run.sh new file mode 100755 index 00000000..6f318894 --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/template-agent/run.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +# Runs the shipped template, unmodified, against the evaluation set it ships +# with. Everything the template owns is copied in rather than duplicated here, +# so the testcase fails when the template and its evaluators drift apart. + +TEMPLATE_DIR="../../template" + +echo "Copying the template's agent and evaluations..." +cp "$TEMPLATE_DIR/main.py" main.py +cp "$TEMPLATE_DIR/input.json" input.json +cp "$TEMPLATE_DIR/uipath.json" uipath.json +cp "$TEMPLATE_DIR/claude.json" claude.json +rm -rf evaluations +cp -r "$TEMPLATE_DIR/evaluations" evaluations + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Running the evaluation set..." +uv run uipath eval --no-report --output-file eval_output.json --trace-file traces.jsonl + +echo "Running agent..." +echo "Input from input.json file" +uv run uipath run agent --file input.json diff --git a/packages/uipath-claude-sdk/testcases/template-agent/src/assert.py b/packages/uipath-claude-sdk/testcases/template-agent/src/assert.py new file mode 100644 index 00000000..ad2bd5ba --- /dev/null +++ b/packages/uipath-claude-sdk/testcases/template-agent/src/assert.py @@ -0,0 +1,194 @@ +import json +import os +import sys +import zipfile + +print("Checking template agent evaluation...") + +TOOL_NAME = "mcp__weather__get_weather" +JUDGE_ID = "evaluator-llm-judge-output" +TOOL_EVALUATOR_IDS = ( + "evaluator-tool-call-order", + "evaluator-tool-call-count", + "evaluator-tool-call-arguments", +) +EVALUATOR_ERROR_PREFIX = "Exception thrown by evaluator:" + +# Check the package +uipath_dir = ".uipath" +if not os.path.exists(uipath_dir): + print("NuGet package directory (.uipath) not found") + sys.exit(1) + +nupkg_files = [f for f in os.listdir(uipath_dir) if f.endswith(".nupkg")] +if not nupkg_files: + print("NuGet package file (.nupkg) not found in .uipath directory") + sys.exit(1) + +print(f"NuGet package found: {nupkg_files[0]}") + +with zipfile.ZipFile(os.path.join(uipath_dir, nupkg_files[0])) as nupkg: + packed_evaluations = [n for n in nupkg.namelist() if "evaluations/" in n] +if packed_evaluations: + print(f"Evaluations were packed into the nupkg: {packed_evaluations}") + sys.exit(1) + +print("Evaluations are excluded from the nupkg") + +# Check the agent's own run +output_file = "__uipath/output.json" +if not os.path.isfile(output_file): + print("Agent output file not found") + sys.exit(1) + +print("Agent output file found") + +try: + with open(output_file, "r", encoding="utf-8") as f: + output_data = json.load(f) + + status = output_data.get("status") + if status != "successful": + print(f"Agent execution failed with status: {status}") + sys.exit(1) + + print("Agent execution status: successful") + + output_content = output_data.get("output") + if not output_content: + print("Missing 'output' field in agent response") + sys.exit(1) + + for field in ("city", "temperature_celsius", "summary"): + if field not in output_content: + print(f"Missing '{field}' field in output") + sys.exit(1) + + # input.json asks for London, for which the tool's table returns 14.0. A + # different number means the model answered without consulting the tool. + temperature = float(output_content["temperature_celsius"]) + if abs(temperature - 14.0) > 0.01: + print(f"Temperature {temperature} is not the 14.0 the tool returns") + sys.exit(1) + + print(f"City: {output_content['city']}") + print(f"Temperature: {temperature}") + print(f"Summary: {output_content['summary']}") + +except Exception as e: + print(f"Error checking output: {e}") + sys.exit(1) + +# Check the evaluation results +eval_output_file = "eval_output.json" +if not os.path.isfile(eval_output_file): + print("Evaluation output file not found") + sys.exit(1) + +print("Evaluation output file found") + +with open(eval_output_file, "r", encoding="utf-8") as f: + eval_data = json.load(f) + +set_results = eval_data.get("evaluationSetResults") or [] +if not set_results: + print("No evaluation results were produced") + sys.exit(1) + +failed = False +seen_ids = set() + +for evaluation in set_results: + name = evaluation.get("evaluationName") + run_results = evaluation.get("evaluationRunResults") or [] + if not run_results: + print(f"Evaluation '{name}' produced no evaluator results") + sys.exit(1) + + for run_result in run_results: + evaluator_id = run_result.get("evaluatorId") + evaluator_name = run_result.get("evaluatorName") + result = run_result.get("result") or {} + score = result.get("score") + details = result.get("details") + seen_ids.add(evaluator_id) + + errored = isinstance(details, str) and details.startswith( + EVALUATOR_ERROR_PREFIX + ) + + if evaluator_id in TOOL_EVALUATOR_IDS: + if errored: + print(f"{evaluator_name}: evaluator raised: {details[:400]}") + failed = True + elif score != 1.0: + print(f"{evaluator_name}: scored {score}, expected 1.0") + print(f" details: {details}") + failed = True + else: + print(f"{evaluator_name}: {score}") + + elif evaluator_id == JUDGE_ID: + # The judge calls the UiPath LLM gateway, so whether it can run at + # all depends on the tenant's governance policy for the model. A + # refusal is not a defect in the agent, so it is reported and not + # failed on. A score the judge actually computed is failed on. + if errored: + print(f"{evaluator_name}: SKIPPED, evaluator raised:") + print(f" {details[:400]}") + elif score is None or score < 0.7: + print(f"{evaluator_name}: scored {score}, expected at least 0.7") + print(f" details: {details}") + failed = True + else: + print(f"{evaluator_name}: {score}") + +for expected_id in TOOL_EVALUATOR_IDS + (JUDGE_ID,): + if expected_id not in seen_ids: + print(f"Evaluation set never ran '{expected_id}'") + failed = True + +# Check the spans the tool call evaluators read +traces_file = "traces.jsonl" +if not os.path.isfile(traces_file): + print("Evaluation trace file not found") + sys.exit(1) + +tool_spans = [] +with open(traces_file, "r", encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + span = json.loads(line) + attributes = span.get("attributes") + if not isinstance(attributes, dict): + try: + attributes = json.loads(span.get("Attributes", "{}")) + except json.JSONDecodeError: + attributes = {} + if "tool.name" in attributes: + tool_spans.append(attributes) + +names = [attributes["tool.name"] for attributes in tool_spans] +if TOOL_NAME not in names: + print(f"No '{TOOL_NAME}' span in the evaluation trace, found: {names}") + failed = True +else: + print(f"Tool span found: {TOOL_NAME}") + +# tool.id carries a per-invocation tool_use_id, which the evaluators would +# match criteria against instead of the name, scoring every one of them 0. +with_tool_id = [ + attributes["tool.name"] for attributes in tool_spans if "tool.id" in attributes +] +if with_tool_id: + print(f"Tool spans still carry tool.id: {with_tool_id}") + failed = True +else: + print("No tool span carries a tool.id") + +if failed: + print("Template agent evaluation failed.") + sys.exit(1) + +print("Template agent evaluation working correctly.") diff --git a/packages/uipath-claude-sdk/tests/__init__.py b/packages/uipath-claude-sdk/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-claude-sdk/tests/conftest.py b/packages/uipath-claude-sdk/tests/conftest.py new file mode 100644 index 00000000..b323af20 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/conftest.py @@ -0,0 +1,164 @@ +"""Shared fixtures for uipath-claude-sdk tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + ToolUseBlock, +) +from claude_agent_sdk.types import DeferredToolUse +from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor +from pydantic import BaseModel + +from uipath_claude_sdk import ClaudeAgent +from uipath_claude_sdk.runtime.session_paths import ClaudeSessionPaths +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore +from uipath_claude_sdk.runtime.storage import SqliteResumableStorage + + +@pytest.fixture(autouse=True) +def uninstrumented_claude_agent_sdk(): + """Undo the global SDK patching a runtime factory installs. + + ``ClaudeAgentSDKInstrumentor`` monkeypatches ``claude_agent_sdk`` for the + whole process, so a test that builds a factory would otherwise decide what + every later test runs against. + """ + yield + instrumentor = ClaudeAgentSDKInstrumentor() + if instrumentor.is_instrumented_by_opentelemetry: + instrumentor.uninstrument() + + +class SampleInput(BaseModel): + topic: str + + +class SampleOutput(BaseModel): + summary: str + + +@pytest.fixture +def structured_agent() -> ClaudeAgent: + return ClaudeAgent( + options=ClaudeAgentOptions(model="anthropic.claude-sonnet-4-5-20250929-v1:0"), + input_schema=SampleInput, + output_schema=SampleOutput, + prompt="Summarize {topic}", + name="research", + ) + + +@pytest.fixture +def plain_agent() -> ClaudeAgent: + return ClaudeAgent( + options=ClaudeAgentOptions(model="anthropic.claude-sonnet-4-5-20250929-v1:0"), + ) + + +def make_result_message( + *, + result: str | None = "done", + structured_output: Any = None, + is_error: bool = False, + session_id: str = "session-1", + deferred_tool_use: DeferredToolUse | None = None, +) -> ResultMessage: + return ResultMessage( + subtype="success" if not is_error else "error", + duration_ms=1, + duration_api_ms=1, + is_error=is_error, + num_turns=1, + session_id=session_id, + result=result, + structured_output=structured_output, + deferred_tool_use=deferred_tool_use, + ) + + +def make_session_paths(tmp_path: Path, runtime_id: str = "rt-1") -> ClaudeSessionPaths: + return ClaudeSessionPaths.for_runtime(tmp_path / "__uipath", runtime_id) + + +@pytest.fixture +async def storage(tmp_path: Path): + """A state database disposed once the test finishes.""" + instance = SqliteResumableStorage(str(tmp_path / "state.db")) + try: + yield instance + finally: + await instance.dispose() + + +@pytest.fixture +def session_store(storage: SqliteResumableStorage) -> ClaudeSessionStore: + return ClaudeSessionStore(storage, "rt-1") + + +class FakeClaudeSDKClient: + """Fake ClaudeSDKClient yielding a scripted message sequence.""" + + last_options: ClaudeAgentOptions | None = None + last_query: str | None = None + scripted_messages: list[Any] = [] + + def __init__(self, options: ClaudeAgentOptions) -> None: + type(self).last_options = options + self._stream: Any = None + + async def __aenter__(self) -> "FakeClaudeSDKClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def query(self, message: str) -> None: + type(self).last_query = message + + async def receive_response(self): + """Stop at the result, as the SDK's own convenience method does.""" + async for message in self.receive_messages(): + yield message + if isinstance(message, ResultMessage): + return + + async def receive_messages(self): + """Draw from one stream, so a later turn does not replay an earlier one.""" + if self._stream is None: + self._stream = iter(type(self).scripted_messages) + for message in self._stream: + yield message + + +@pytest.fixture +def fake_client(monkeypatch: pytest.MonkeyPatch) -> type[FakeClaudeSDKClient]: + """Patch ClaudeSDKClient in both runtime modules and bypass the gateway proxy.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setattr( + "uipath_claude_sdk.runtime.runtime.ClaudeSDKClient", FakeClaudeSDKClient + ) + monkeypatch.setattr( + "uipath_claude_sdk.runtime.conversational_runtime.ClaudeSDKClient", + FakeClaudeSDKClient, + ) + FakeClaudeSDKClient.scripted_messages = [ + AssistantMessage( + content=[ + TextBlock(text="Hello"), + ToolUseBlock(id="tu-1", name="my_tool", input={"a": 1}), + ], + model="anthropic.claude-sonnet-4-5-20250929-v1:0", + ), + make_result_message(result="Hello", structured_output={"summary": "Hello"}), + ] + FakeClaudeSDKClient.last_options = None + FakeClaudeSDKClient.last_query = None + return FakeClaudeSDKClient diff --git a/packages/uipath-claude-sdk/tests/test_background_tasks_live.py b/packages/uipath-claude-sdk/tests/test_background_tasks_live.py new file mode 100644 index 00000000..ab9223d2 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_background_tasks_live.py @@ -0,0 +1,105 @@ +"""Opt-in live coverage for Claude background-task lifecycle handling.""" + +from __future__ import annotations + +import asyncio +import os + +import pytest +from claude_agent_sdk import ( + AgentDefinition, + ClaudeAgentOptions, + create_sdk_mcp_server, + tool, +) +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.events import UiPathRuntimeStateEvent + +from tests.conftest import make_session_paths +from uipath_claude_sdk import UiPathClaudeAgent, UiPathModel +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore +from uipath_claude_sdk.runtime.storage import SqliteResumableStorage + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("UIPATH_CLAUDE_LIVE_TEST") != "1", + reason="set UIPATH_CLAUDE_LIVE_TEST=1 to call the live UiPath LLM Gateway", + ), +] + +MARKER = "BACKGROUND_WORKER_FINISHED" + + +@tool("slow_marker", "Wait briefly, then return the live-test marker.", {}) +async def slow_marker(_args: dict[str, object]) -> dict[str, object]: + await asyncio.sleep(3) + return {"content": [{"type": "text", "text": MARKER}]} + + +async def test_runtime_waits_for_background_subagent_follow_up(tmp_path) -> None: + """The first result frame must not close a still-running SDK-MCP worker.""" + server = create_sdk_mcp_server("live-background", tools=[slow_marker]) + agent = UiPathClaudeAgent( + options=ClaudeAgentOptions( + system_prompt=( + "Delegate the request to the worker exactly once. The worker is " + "configured to run in the background. Do not poll it and do not " + "call any other tool. End the spawning turn immediately. When its " + f"completion arrives, answer with exactly {MARKER}." + ), + allowed_tools=["Agent"], + mcp_servers={"live-background": server}, + agents={ + "worker": AgentDefinition( + description="Runs the slow marker tool for the live test.", + prompt=( + "Call mcp__live-background__slow_marker exactly once, wait " + f"for its result, then return exactly {MARKER}." + ), + tools=["mcp__live-background__slow_marker"], + mcpServers=["live-background"], + model="inherit", + background=True, + maxTurns=4, + permissionMode="bypassPermissions", + ) + }, + max_turns=8, + permission_mode="bypassPermissions", + ), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + ) + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = UiPathClaudeSDKRuntime( + agent=agent, + session_store=ClaudeSessionStore(storage, "live-background"), + session_paths=make_session_paths(tmp_path, "live-background"), + runtime_id="live-background", + ) + + try: + async with asyncio.timeout(240): + events = [ + event + async for event in runtime.stream( + {"input": "Run the background worker."} + ) + ] + finally: + await storage.dispose() + + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert MARKER in result.output["result"] + + task_events = [ + event + for event in events + if isinstance(event, UiPathRuntimeStateEvent) and event.node_name == "task" + ] + assert task_events + assert any(event.payload.get("status") == "completed" for event in task_events) diff --git a/packages/uipath-claude-sdk/tests/test_cli_contract.py b/packages/uipath-claude-sdk/tests/test_cli_contract.py new file mode 100644 index 00000000..d56c465e --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_cli_contract.py @@ -0,0 +1,94 @@ +"""Version-drift guard for the pinned claude-agent-sdk and its bundled CLI. + +Almost everything this package depends on for suspend and resume lives in the +Claude Code CLI binary shipped inside the claude-agent-sdk wheel, not in the +Python package. A routine dependency bump can therefore change runtime +behaviour while every Python signature stays identical. These tests are the +tripwire: they are hermetic (no subprocess, no network, no tenant, no API key) +and they fail loudly when the bundled CLI or the Python-side primitives move. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, get_args, get_origin, get_type_hints + +import claude_agent_sdk +from claude_agent_sdk import create_sdk_mcp_server, tool +from claude_agent_sdk._cli_version import __cli_version__ +from claude_agent_sdk.types import ( + DeferredToolUse, + HookMatcher, + PreToolUseHookSpecificOutput, + ResultMessage, +) + +from tests.conftest import make_result_message + +VALIDATED_CLI_VERSIONS = frozenset({"2.1.224"}) + + +def test_bundled_cli_version_is_validated(): + assert __cli_version__ in VALIDATED_CLI_VERSIONS, ( + f"claude-agent-sdk {claude_agent_sdk.__version__} bundles Claude Code CLI " + f"{__cli_version__}, which this package has not been validated against. " + "The suspend primitive is implemented in the CLI binary, so a Python-only " + "review proves nothing. Before adding a version here, drive a real agent " + "against this CLI and confirm the full round trip: a PreToolUse hook " + "returning permissionDecision 'defer' parks the call without running the " + "tool and reports it as ResultMessage.deferred_tool_use, and resuming that " + "session in a NEW process re-issues the same tool_use_id so the handler " + "runs with its original arguments. Then add " + f'"{__cli_version__}" to VALIDATED_CLI_VERSIONS in this file.' + ) + + +def test_defer_is_an_accepted_permission_decision(): + hints = get_type_hints(PreToolUseHookSpecificOutput) + assert "defer" in get_args(hints["permissionDecision"]) + + +def test_deferred_tool_use_shape(): + fields = {field.name: field for field in dataclasses.fields(DeferredToolUse)} + assert set(fields) == {"id", "name", "input"} + + hints = get_type_hints(DeferredToolUse) + assert hints["id"] is str + assert hints["name"] is str + assert get_origin(hints["input"]) is dict + assert get_args(hints["input"]) == (str, Any) + + +def test_result_message_carries_deferred_tool_use(): + field_names = {field.name for field in dataclasses.fields(ResultMessage)} + assert "deferred_tool_use" in field_names + + hints = get_type_hints(ResultMessage) + assert DeferredToolUse in get_args(hints["deferred_tool_use"]) + + +def test_suspend_signal_is_deferred_tool_use_not_terminal_reason(): + """The runtime detects a suspension through deferred_tool_use, never + terminal_reason. + + terminal_reason is present on the currently pinned sdk but was absent on + 0.2.110, where the defer round trip was also verified to work. Reading it + would therefore couple the runtime to a field that is not guaranteed across + the supported range, for no benefit: deferred_tool_use is the signal and it + carries the pending call as well. + """ + result = make_result_message() + assert result.deferred_tool_use is None + + +def test_hook_matcher_accepts_a_timeout(): + field_names = {field.name for field in dataclasses.fields(HookMatcher)} + assert "timeout" in field_names + + matcher = HookMatcher(matcher="Bash", hooks=[], timeout=1.5) + assert matcher.timeout == 1.5 + + +def test_sdk_mcp_server_primitives_are_importable(): + assert callable(tool) + assert callable(create_sdk_mcp_server) diff --git a/packages/uipath-claude-sdk/tests/test_cli_new.py b/packages/uipath-claude-sdk/tests/test_cli_new.py new file mode 100644 index 00000000..cb23a060 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_cli_new.py @@ -0,0 +1,82 @@ +"""What ``uipath new`` leaves on disk for a Claude SDK agent. + +The command is registered as a ``new`` middleware, so it runs in the user's +current directory and reports through the platform console rather than +returning anything the CLI inspects. These tests pin the scaffold it writes and +the contract it returns, including the failure path, where a raised error has to +become a reported one or the CLI would carry on and scaffold a second time. +""" + +from __future__ import annotations + +import os +import shutil +import tomllib +from pathlib import Path + +import click +import pytest + +from uipath_claude_sdk._cli import cli_new +from uipath_claude_sdk._cli.cli_new import claude_new_middleware +from uipath_claude_sdk.middlewares import register_middleware + +TEMPLATES = Path(cli_new.__file__).parent / "_templates" + + +@pytest.fixture +def in_project(tmp_path: Path): + previous = os.getcwd() + os.chdir(tmp_path) + try: + yield tmp_path + finally: + os.chdir(previous) + + +def test_new_writes_the_agent_and_its_config(in_project: Path): + result = claude_new_middleware("weather-agent") + + assert result.should_continue is False + assert (in_project / "main.py").read_text() == ( + TEMPLATES / "main.py.template" + ).read_text() + assert (in_project / "claude.json").read_text() == ( + TEMPLATES / "claude.json.template" + ).read_text() + + +def test_new_names_the_project_after_the_agent(in_project: Path): + claude_new_middleware("weather-agent") + + project = tomllib.loads((in_project / "pyproject.toml").read_text())["project"] + assert project["name"] == "weather-agent" + assert project["description"] == "weather-agent" + assert project["requires-python"] == ">=3.11" + assert any(dep.startswith("uipath-claude-sdk") for dep in project["dependencies"]) + + +def test_new_fails_the_command_instead_of_half_scaffolding( + in_project: Path, monkeypatch +): + """``console.error`` ends the command, so the middleware never returns here.""" + + def refuse(*args: object, **kwargs: object) -> None: + raise OSError("read-only file system") + + monkeypatch.setattr(shutil, "copyfile", refuse) + + with click.Context(click.Command("new")): + with pytest.raises(click.exceptions.Exit) as exit_info: + claude_new_middleware("weather-agent") + + assert exit_info.value.exit_code == 1 + assert not (in_project / "pyproject.toml").exists() + + +def test_new_is_registered_as_the_new_middleware(): + from uipath._cli.middlewares import Middlewares + + register_middleware() + + assert claude_new_middleware in Middlewares._middlewares["new"] diff --git a/packages/uipath-claude-sdk/tests/test_config_and_loader.py b/packages/uipath-claude-sdk/tests/test_config_and_loader.py new file mode 100644 index 00000000..4d43aa79 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_config_and_loader.py @@ -0,0 +1,85 @@ +"""Tests for claude.json config parsing and the agent loader.""" + +from __future__ import annotations + +import json +import os + +import pytest + +from uipath_claude_sdk import ClaudeAgent +from uipath_claude_sdk.runtime.config import ClaudeConfig +from uipath_claude_sdk.runtime.errors import UiPathClaudeSDKRuntimeError +from uipath_claude_sdk.runtime.loader import ClaudeAgentLoader + + +def test_config_string_entries(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "claude.json").write_text( + json.dumps({"agents": {"agent": "main.py:agent"}}) + ) + config = ClaudeConfig() + assert config.exists + assert config.entrypoint == ["agent"] + assert config.agents["agent"] == "main.py:agent" + + +def test_config_invalid_entry(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "claude.json").write_text(json.dumps({"agents": {"bad": 42}})) + with pytest.raises(ValueError, match="Missing or invalid 'agents'"): + _ = ClaudeConfig().agents + + +def test_loader_invalid_path_format(): + with pytest.raises(UiPathClaudeSDKRuntimeError): + ClaudeAgentLoader.from_path_string("agent", "main.py") + + +async def test_loader_loads_claude_agent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text( + "from claude_agent_sdk import ClaudeAgentOptions\n" + "from uipath_claude_sdk import ClaudeAgent\n" + "agent = ClaudeAgent(options=ClaudeAgentOptions(model='claude-sonnet-4-5'))\n" + ) + loader = ClaudeAgentLoader.from_path_string("agent", "main.py:agent") + agent = await loader.load() + assert isinstance(agent, ClaudeAgent) + assert agent.options.model == "claude-sonnet-4-5" + + +async def test_loader_wraps_bare_options(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text( + "from claude_agent_sdk import ClaudeAgentOptions\n" + "agent = ClaudeAgentOptions(model='claude-sonnet-4-5')\n" + ) + loader = ClaudeAgentLoader.from_path_string("agent", "main.py:agent") + agent = await loader.load() + assert isinstance(agent, ClaudeAgent) + assert agent.options.model == "claude-sonnet-4-5" + assert agent.name == "agent" + + +async def test_loader_rejects_paths_outside_cwd(tmp_path, monkeypatch): + outside = tmp_path / "outside.py" + outside.write_text("agent = None\n") + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + loader = ClaudeAgentLoader.from_path_string( + "agent", os.path.join("..", "outside.py") + ":agent" + ) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="working directory"): + await loader.load() + + +async def test_loader_rejects_wrong_type(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text("agent = 42\n") + loader = ClaudeAgentLoader.from_path_string("agent", "main.py:agent") + with pytest.raises( + UiPathClaudeSDKRuntimeError, match="Expected uipath_claude_sdk.ClaudeAgent" + ): + await loader.load() diff --git a/packages/uipath-claude-sdk/tests/test_conversational.py b/packages/uipath-claude-sdk/tests/test_conversational.py new file mode 100644 index 00000000..44d78e7d --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_conversational.py @@ -0,0 +1,354 @@ +"""Tests for the conversational runtime: event ordering, suspension, session resume.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from claude_agent_sdk import ( + AssistantMessage, + TaskNotificationMessage, + TaskStartedMessage, + TextBlock, +) +from uipath.core.chat import UiPathConversationMessageEvent +from uipath.core.triggers import UiPathResumeTrigger +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathResumableRuntime, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.chat import UiPathChatRuntime +from uipath.runtime.events import UiPathRuntimeMessageEvent + +from tests.conftest import make_result_message, make_session_paths +from uipath_claude_sdk.interrupts import PendingSuspend +from uipath_claude_sdk.runtime.conversational_runtime import ( + UiPathClaudeSDKConversationalRuntime, +) +from uipath_claude_sdk.runtime.runtime import RESUME_PROMPT +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore +from uipath_claude_sdk.runtime.storage import SqliteResumableStorage + + +def _make_runtime(agent, tmp_path: Path, storage: SqliteResumableStorage): + return UiPathClaudeSDKConversationalRuntime( + agent=agent, + session_store=ClaudeSessionStore(storage, "rt-1"), + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + ) + + +def _conversation_input(text: str) -> dict[str, Any]: + return { + "messages": [{"role": "user", "contentParts": [{"data": {"inline": text}}]}] + } + + +class RecordingChatBridge: + """A chat host that records what an exchange told it.""" + + def __init__(self) -> None: + self.messages: list[UiPathConversationMessageEvent] = [] + self.errors: list[Exception] = [] + self.exchange_ended = False + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + async def emit_message_event( + self, message_event: UiPathConversationMessageEvent + ) -> None: + self.messages.append(message_event) + + async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger) -> None: + return None + + async def emit_executing_tool_call_event( + self, tool_call_id: str, tool_input: dict[str, Any] | None = None + ) -> None: + return None + + async def emit_exchange_end_event(self) -> None: + self.exchange_ended = True + + async def emit_exchange_error_event(self, error: Exception) -> None: + self.errors.append(error) + + async def wait_for_resume(self) -> dict[str, Any]: + raise AssertionError("the host was asked to answer a turn that was over") + + +def _chat_stack( + agent, + tmp_path: Path, + storage: SqliteResumableStorage, + bridge: RecordingChatBridge, +) -> UiPathChatRuntime: + """The runtime stack a conversational job actually runs on.""" + return UiPathChatRuntime( + delegate=UiPathResumableRuntime( + delegate=_make_runtime(agent, tmp_path, storage), + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id="rt-1", + ), + chat_bridge=bridge, + ) + + +async def test_exchange_streams_and_suspends(plain_agent, fake_client, tmp_path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + events = [event async for event in runtime.stream(_conversation_input("hi there"))] + + assert fake_client.last_query == "hi there" + + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert not result.output + + message_events = [ + e.payload for e in events if isinstance(e, UiPathRuntimeMessageEvent) + ] + assert message_events[0].start is not None + assert message_events[0].start.role == "assistant" + assert message_events[0].content_part.start is not None + assert message_events[1].content_part.chunk.data == "Hello" + assert message_events[-1].end is not None + + session_store = ClaudeSessionStore(storage, "rt-1") + assert await session_store.get_session_id() == "session-1" + await storage.dispose() + + +async def test_exchange_waits_for_background_subagent_follow_up( + plain_agent, fake_client, tmp_path +): + fake_client.scripted_messages = [ + TaskStartedMessage( + subtype="task_started", + data={}, + task_id="agent-1", + description="review", + uuid="task-start-1", + session_id="session-1", + task_type="local_agent", + ), + make_result_message(result="Review launched."), + TaskNotificationMessage( + subtype="task_notification", + data={}, + task_id="agent-1", + status="completed", + output_file="agent-1.output", + summary="Review complete", + uuid="task-end-1", + session_id="session-1", + ), + AssistantMessage( + content=[TextBlock(text="Review complete: all good.")], + model="claude-sonnet-4-5", + ), + make_result_message(result="Review complete: all good."), + ] + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + events = [event async for event in runtime.stream(_conversation_input("review"))] + + chunks = [ + event.payload.content_part.chunk.data + for event in events + if isinstance(event, UiPathRuntimeMessageEvent) + and event.payload.content_part is not None + and event.payload.content_part.chunk is not None + ] + assert chunks == ["Review complete: all good."] + assert isinstance(events[-1], UiPathRuntimeResult) + assert events[-1].status == UiPathRuntimeStatus.SUSPENDED + await storage.dispose() + + +async def test_resume_reattaches_session(plain_agent, fake_client, tmp_path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + async for _ in runtime.stream(_conversation_input("first message")): + pass + assert fake_client.last_options.resume is None + + fake_client.scripted_messages = [ + make_result_message(result="again", session_id="session-2") + ] + + resume_input = {"interrupt-1": _conversation_input("second message")} + async for _ in runtime.stream(resume_input): + pass + + assert fake_client.last_query == "second message" + assert fake_client.last_options.resume == "session-1" + + session_store = ClaudeSessionStore(storage, "rt-1") + assert await session_store.get_session_id() == "session-2" + await storage.dispose() + + +async def test_workspace_is_stable_across_exchanges(plain_agent, fake_client, tmp_path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + async for _ in runtime.stream(_conversation_input("one")): + pass + first_cwd = fake_client.last_options.cwd + + async for _ in runtime.stream({"i": _conversation_input("two")}): + pass + assert fake_client.last_options.cwd == first_cwd + assert Path(first_cwd).exists() + await storage.dispose() + + +async def test_next_exchange_carries_the_user_message( + plain_agent, fake_client, tmp_path +): + """Every exchange after the first arrives as a resume, message and all. + + The base runtime replaces the prompt with a fixed continuation whenever it + resumes, because there a resume only ever delivers a parked tool call. A + conversation resumes to say something new, so this pins that the second + turn reaches the model instead of "Continue.". + """ + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + async for _ in runtime.stream(_conversation_input("first message")): + pass + + fake_client.scripted_messages = [ + make_result_message(result="again", session_id="session-1") + ] + resume_input = {"interrupt-1": _conversation_input("second message")} + async for _ in runtime.stream( + resume_input, UiPathStreamOptions(resume=True, execution_id="x") + ): + pass + + assert fake_client.last_query == "second message" + assert fake_client.last_options.resume == "session-1" + await storage.dispose() + + +async def test_resume_of_a_parked_call_adds_no_prompt( + plain_agent, fake_client, tmp_path +): + """A payload answering an interrupt is the tool's result, not a new turn.""" + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + session_store = ClaudeSessionStore(storage, "rt-1") + await session_store.set_session_id("session-1") + await session_store.set_pending_suspend( + PendingSuspend( + interrupt_id="toolu_1", + tool_name="mcp__approvals__ask", + value="Approve?", + tool_use_id="toolu_1", + ) + ) + runtime = _make_runtime(plain_agent, tmp_path, storage) + + async for _ in runtime.stream( + {"toolu_1": {"answer": "yes"}}, + UiPathStreamOptions(resume=True, execution_id="x"), + ): + pass + + assert fake_client.last_query == RESUME_PROMPT + await storage.dispose() + + +async def test_transcript_is_stored_after_every_exchange( + plain_agent, fake_client, tmp_path +): + """Only the state database crosses a suspension, so each turn stores one.""" + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + transcript = runtime._session_paths.transcript_path("session-1") + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text('{"type": "user"}\n', encoding="utf-8") + + async for _ in runtime.stream(_conversation_input("hi")): + pass + + record = await ClaudeSessionStore(storage, "rt-1").get_transcript() + assert record is not None + assert record.session_id == "session-1" + await storage.dispose() + + +async def test_a_finished_exchange_is_closed_on_the_chat_host( + plain_agent, fake_client, tmp_path, storage +): + """The turn is only over once the host has been told so. + + ``UiPathChatRuntime`` ends an exchange on a result that carries no + interrupt, and treats one carrying an API interrupt as a tool call it still + owes an answer to. Suspending on a synthetic interrupt therefore leaves the + composer waiting on a turn that already finished. + """ + bridge = RecordingChatBridge() + events = [ + event + async for event in _chat_stack(plain_agent, tmp_path, storage, bridge).stream( + _conversation_input("hi") + ) + ] + + assert bridge.exchange_ended + assert not bridge.errors + assert bridge.messages + + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert not result.triggers + + +async def test_the_next_message_reaches_the_agent_through_the_chat_host( + plain_agent, fake_client, tmp_path, storage +): + """A second exchange is a second process over the same state database.""" + async for _ in _chat_stack( + plain_agent, tmp_path, storage, RecordingChatBridge() + ).stream(_conversation_input("first message")): + pass + + fake_client.scripted_messages = [ + make_result_message(result="again", session_id="session-1") + ] + bridge = RecordingChatBridge() + async for _ in _chat_stack(plain_agent, tmp_path, storage, bridge).stream( + _conversation_input("second message"), UiPathStreamOptions(resume=True) + ): + pass + + assert fake_client.last_query == "second message" + assert fake_client.last_options.resume == "session-1" + assert bridge.exchange_ended + assert not bridge.errors + + +async def test_conversational_schema(plain_agent, tmp_path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = _make_runtime(plain_agent, tmp_path, storage) + schema = await runtime.get_schema() + assert "messages" in schema.input["properties"] + assert "messages" in schema.output["properties"] + await storage.dispose() diff --git a/packages/uipath-claude-sdk/tests/test_debug.py b/packages/uipath-claude-sdk/tests/test_debug.py new file mode 100644 index 00000000..0774cc53 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_debug.py @@ -0,0 +1,242 @@ +"""Tests for what `uipath debug` needs from this runtime. + +The debug command wraps the runtime in ``UiPathDebugRuntime``. These tests +drive that real stack over a scripted bridge, so the contract is checked +rather than assumed. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Literal + +import pytest +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathResumableRuntime, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.debug import UiPathBreakpointResult, UiPathDebugRuntime +from uipath.runtime.events import UiPathRuntimeStateEvent + +from tests.conftest import make_session_paths +from tests.test_suspend_resume import InterruptClient, hitl_options # noqa: F401 +from uipath_claude_sdk import ClaudeAgent +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore +from uipath_claude_sdk.runtime.storage import SqliteResumableStorage + + +class ScriptedBridge: + """A debug bridge that answers the first wait and quits after that.""" + + def __init__(self, resume_payload: Any = None) -> None: + self.log: list[str] = [] + self.state_events: list[UiPathRuntimeStateEvent] = [] + self.resume_payload = resume_payload + self._resumes = 0 + + async def connect(self) -> None: + self.log.append("connect") + + async def disconnect(self) -> None: + self.log.append("disconnect") + + async def emit_execution_started(self, **kwargs: Any) -> None: + self.log.append("started") + + async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None: + self.state_events.append(state_event) + + async def emit_breakpoint_hit(self, breakpoint_result: UiPathBreakpointResult): + self.log.append("breakpoint") + + async def emit_execution_suspended(self, runtime_result: UiPathRuntimeResult): + trigger = runtime_result.trigger + assert trigger is not None + self.log.append(f"suspended:{trigger.trigger_type.name}") + + async def emit_execution_resumed(self, resume_data: Any) -> None: + self.log.append("resumed") + + async def emit_execution_completed(self, runtime_result: UiPathRuntimeResult): + self.log.append(f"completed:{runtime_result.status.name}") + + async def emit_execution_error(self, error: str) -> None: + self.log.append(f"error:{error}") + + async def wait_for_resume(self) -> Any: + self._resumes += 1 + return self.resume_payload + + async def wait_for_terminate(self) -> None: + return None + + def get_breakpoints(self) -> list[str] | Literal["*"]: + return [] + + +def _debug_stack( + agent: ClaudeAgent, + tmp_path: Path, + storage: SqliteResumableStorage, + bridge: ScriptedBridge, +) -> UiPathDebugRuntime: + runtime = UiPathClaudeSDKRuntime( + agent=agent, + session_store=ClaudeSessionStore(storage, "rt-1"), + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + return UiPathDebugRuntime( + delegate=UiPathResumableRuntime( + delegate=runtime, + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id="rt-1", + ), + debug_bridge=bridge, + ) + + +async def _drain(runtime: UiPathDebugRuntime, input: dict[str, Any]) -> list[Any]: + return [event async for event in runtime.stream(input, UiPathStreamOptions())] + + +async def test_a_human_in_the_loop_run_completes_through_the_debug_stack( + interrupt_client, tmp_path, storage +): + """The interactive loop `uipath debug` runs: suspend, answer, finish.""" + agent = ClaudeAgent(options=hitl_options()) + bridge = ScriptedBridge(resume_payload="ship it") + events = await _drain( + _debug_stack(agent, tmp_path, storage, bridge), {"input": "ask me"} + ) + + results = [e for e in events if isinstance(e, UiPathRuntimeResult)] + assert [r.status for r in results] == [ + UiPathRuntimeStatus.SUSPENDED, + UiPathRuntimeStatus.SUCCESSFUL, + ] + assert bridge.log == [ + "connect", + "started", + "suspended:API", + "resumed", + "completed:SUCCESSFUL", + ] + assert interrupt_client.tool_results == ["ship it"] + + +async def test_state_events_reach_the_debug_bridge( + structured_agent, fake_client, tmp_path, storage +): + bridge = ScriptedBridge() + await _drain( + _debug_stack(structured_agent, tmp_path, storage, bridge), {"topic": "otters"} + ) + + assert [e.node_name for e in bridge.state_events] == ["assistant", "tool_call"] + + +async def test_breakpoints_are_reported_as_unsupported_once( + structured_agent, fake_client, tmp_path, session_store, caplog +): + runtime = UiPathClaudeSDKRuntime( + agent=structured_agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + with caplog.at_level(logging.WARNING, logger="uipath_claude_sdk.runtime.runtime"): + async for _ in runtime.stream( + {"topic": "otters"}, UiPathStreamOptions(breakpoints="*") + ): + pass + + warnings = [ + r for r in caplog.records if "Breakpoints are not supported" in r.getMessage() + ] + assert len(warnings) == 1 + + +async def test_no_breakpoints_means_no_warning( + structured_agent, fake_client, tmp_path, session_store, caplog +): + runtime = UiPathClaudeSDKRuntime( + agent=structured_agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + with caplog.at_level(logging.WARNING, logger="uipath_claude_sdk.runtime.runtime"): + async for _ in runtime.stream( + {"topic": "otters"}, UiPathStreamOptions(breakpoints=[]) + ): + pass + + assert [ + r for r in caplog.records if "Breakpoints are not supported" in r.getMessage() + ] == [] + + +async def test_cli_stderr_is_logged( + structured_agent, fake_client, tmp_path, session_store, caplog +): + runtime = UiPathClaudeSDKRuntime( + agent=structured_agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + async for _ in runtime.stream({"topic": "otters"}): + pass + + stderr = fake_client.last_options.stderr + assert stderr is not None + with caplog.at_level(logging.WARNING): + stderr("Error: something went wrong") + assert "Error: something went wrong" in caplog.text + + +async def test_a_developer_stderr_callback_is_kept( + structured_agent, fake_client, tmp_path, session_store +): + seen: list[str] = [] + + def developer_stderr(line: str) -> None: + seen.append(line) + + structured_agent.options.stderr = developer_stderr + + runtime = UiPathClaudeSDKRuntime( + agent=structured_agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + async for _ in runtime.stream({"topic": "otters"}): + pass + + assert fake_client.last_options.stderr is developer_stderr + + +@pytest.fixture +def interrupt_client(monkeypatch: pytest.MonkeyPatch): + from tests.test_suspend_resume import InterruptClient as Client + + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setattr("uipath_claude_sdk.runtime.runtime.ClaudeSDKClient", Client) + Client.last_options = None + Client.last_query = None + Client.tool_results = [] + Client.session_id = "session-1" + return Client diff --git a/packages/uipath-claude-sdk/tests/test_defer_contract.py b/packages/uipath-claude-sdk/tests/test_defer_contract.py new file mode 100644 index 00000000..32dd35c4 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_defer_contract.py @@ -0,0 +1,581 @@ +"""End-to-end proof that a run suspends on the Claude Code CLI defer protocol. + +This is the regression test for the whole durable-suspend design. It drives the +real :class:`UiPathClaudeSDKRuntime` and the real Claude Code CLI binary against +a local HTTP server speaking canned Anthropic SSE, so it needs no tenant, no API +key and no network. A CLI upgrade that changes how a ``PreToolUse`` hook +returning ``permissionDecision: "defer"`` behaves fails here. + +The agent deliberately declares no ``uipath_llm``: this test is about the +suspend protocol, not about the gateway, so the CLI talks to the stub directly. + +The Claude Agent SDK instrumentor is installed throughout, as it is on every +deployed run, so the CLI is handed the merged hook list rather than the one this +package built on its own. An instrumentor release that stopped merging into +``PreToolUse`` would drop the interrupt hook and fail here. + +It spawns a subprocess twice and is therefore slower than the rest of the +suite. Deselect it with ``-m "not defer_contract"``. +""" + +from __future__ import annotations + +import json +import shutil +import threading +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager +from dataclasses import replace +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ClaudeAgentOptions, tool +from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor +from pydantic import BaseModel +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeResult, + UiPathRuntimeStatus, +) + +from uipath_claude_sdk import ClaudeAgent, interrupt, uipath_tool_server +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_paths import ( + ClaudeSessionPaths, + encode_project_dir, +) +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore +from uipath_claude_sdk.runtime.storage import SqliteResumableStorage + +pytestmark = pytest.mark.defer_contract + +MODEL = "claude-sonnet-4-5" +RUNTIME_ID = "defer-contract" +SERVER_NAME = "approvals" +TOOL_NAME = "ask_approver" +INTERRUPT_TOOL = f"mcp__{SERVER_NAME}__{TOOL_NAME}" +TOOL_USE_ID = "toolu_defer_contract" + +PROMPT = "Ship release 1.4.0." +QUESTION = "May I ship release 1.4.0?" +HUMAN_ANSWER = "yes, ship it" +ANSWER_PREFIX = "The human said: " + +DEFER_MARKER = "hook_deferred_tool" + +# Ambient Claude credentials would send the CLI somewhere other than the stub. +_UNSET_IN_SUBPROCESS = ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_VERTEX_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CONFIG_DIR", +) + +_QUIET_CLI_ENV = { + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_TELEMETRY": "1", + "DISABLE_ERROR_REPORTING": "1", + "DISABLE_AUTOUPDATER": "1", +} + + +# --- The stub upstream ------------------------------------------------------ + + +def _sse(*events: tuple[str, dict[str, Any]]) -> bytes: + return "".join( + f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events + ).encode() + + +_MESSAGE_START = ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_defer_contract", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, +) + + +def _ask_for_approval() -> bytes: + return _sse( + _MESSAGE_START, + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": TOOL_USE_ID, + "name": INTERRUPT_TOOL, + "input": {}, + }, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": json.dumps({"question": QUESTION}), + }, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + {"type": "message_delta", "delta": {"stop_reason": "tool_use"}}, + ), + ("message_stop", {"type": "message_stop"}), + ) + + +def _say(text: str) -> bytes: + return _sse( + _MESSAGE_START, + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}, + ), + ("message_stop", {"type": "message_stop"}), + ) + + +def _tool_result_blocks(body: str) -> list[dict[str, Any]]: + """Every ``tool_result`` block carried by one Anthropic request body.""" + try: + payload = json.loads(body) + except json.JSONDecodeError: + return [] + blocks: list[dict[str, Any]] = [] + for message in payload.get("messages", []): + content = message.get("content") + if not isinstance(content, list): + continue + blocks.extend( + block + for block in content + if isinstance(block, dict) and block.get("type") == "tool_result" + ) + return blocks + + +def _tool_result_text(block: dict[str, Any]) -> str: + content = block.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + part.get("text", "") for part in content if isinstance(part, dict) + ) + return "" + + +class FakeAnthropic: + """A local Anthropic stand-in replying with canned SSE on an ephemeral port. + + It asks for the interrupt tool until the conversation carries a + ``tool_result``, then echoes that result back as its final text. The run's + output therefore cannot carry the human answer unless the resumed payload + really travelled to the model as a tool result. + """ + + def __init__(self) -> None: + self.requests: list[str] = [] + self._server: ThreadingHTTPServer | None = None + + @property + def base_url(self) -> str: + assert self._server is not None + return f"http://127.0.0.1:{self._server.server_address[1]}" + + @property + def tool_results(self) -> list[dict[str, Any]]: + """The distinct tool results the conversation carried, oldest first. + + Every request repeats the whole conversation, so a block is counted + once per tool_use id rather than once per request. + """ + seen: dict[str, dict[str, Any]] = {} + for body in self.requests: + for block in _tool_result_blocks(body): + seen.setdefault(str(block.get("tool_use_id")), block) + return list(seen.values()) + + def start(self) -> None: + upstream = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() + upstream.requests.append(body) + blocks = _tool_result_blocks(body) + if blocks: + answer = _say(f"{ANSWER_PREFIX}{_tool_result_text(blocks[-1])}") + else: + answer = _ask_for_approval() + self._reply(answer, "text/event-stream") + + def do_GET(self) -> None: + self._reply(b'{"data":[]}', "application/json") + + def do_HEAD(self) -> None: + self._reply(b"", "text/plain") + + def _reply(self, payload: bytes, content_type: str) -> None: + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: Any) -> None: + pass + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self._server.serve_forever, daemon=True).start() + + def stop(self) -> None: + server, self._server = self._server, None + if server is not None: + server.shutdown() + server.server_close() + + +# --- Fixtures --------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def instrumented() -> None: + """Trace the run the way a deployed one is traced. + + The runtime factory installs this on every run, and with it the CLI receives + the instrumentor's tracing hooks merged alongside the interrupt hook. + ``conftest`` uninstruments once the test is over. + """ + ClaudeAgentSDKInstrumentor().instrument() + assert ClaudeAgentSDKInstrumentor().is_instrumented_by_opentelemetry + + +@pytest.fixture +def upstream(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeAnthropic]: + for name in _UNSET_IN_SUBPROCESS: + monkeypatch.delenv(name, raising=False) + server = FakeAnthropic() + server.start() + try: + yield server + finally: + server.stop() + + +class ToolBodyLog: + """What the developer's own tool body did, across both processes. + + Nothing is injected into the agent, so the only way to observe the protocol + from the inside is the body the test itself wrote. + """ + + def __init__(self) -> None: + self.started: list[dict[str, Any]] = [] + self.returned: list[str] = [] + + +BODY_LOG = ToolBodyLog() + + +@pytest.fixture +def tool_body() -> Iterator[ToolBodyLog]: + BODY_LOG.started.clear() + BODY_LOG.returned.clear() + yield BODY_LOG + + +@tool(TOOL_NAME, "Ask a human a question and wait for the answer.", {"question": str}) +async def ask_approver(args: dict[str, Any]) -> dict[str, Any]: + BODY_LOG.started.append(dict(args)) + answer = str(await interrupt(args["question"])) + BODY_LOG.returned.append(answer) + return {"content": [{"type": "text", "text": answer}]} + + +class Approval(BaseModel): + approved: bool + + +def _agent(base_url: str, output_schema: type[BaseModel] | None = None) -> ClaudeAgent: + return ClaudeAgent( + options=ClaudeAgentOptions( + model=MODEL, + system_prompt="Ask a human for approval before doing anything.", + permission_mode="bypassPermissions", + max_turns=4, + mcp_servers={ + SERVER_NAME: uipath_tool_server(SERVER_NAME, tools=[ask_approver]) + }, + env={ + "ANTHROPIC_BASE_URL": base_url, + "ANTHROPIC_API_KEY": "not-a-real-key", + **_QUIET_CLI_ENV, + }, + ), + output_schema=output_schema, + name="agent", + ) + + +@asynccontextmanager +async def _process( + runtime_dir: Path, + base_url: str, + output_schema: type[BaseModel] | None = None, + state_db: Path | None = None, + workspace: Path | None = None, +) -> AsyncIterator[tuple[UiPathClaudeSDKRuntime, ClaudeSessionStore]]: + """One simulated OS process over a runtime directory. + + Everything a process cannot inherit is rebuilt: the agent, the storage + handle, the runtime, and with it the hooks and the in-process MCP server. + + ``state_db`` splits the two things the platform treats differently. The + state database is carried across a suspension; the directory holding it is + not, and a resumed job is handed a fresh one. Pass a path outside + ``runtime_dir`` to model that. + + ``workspace`` overrides the working directory, which a managed workspace + creates fresh per execution. + """ + storage = SqliteResumableStorage(str(state_db or runtime_dir / "state.db")) + store = ClaudeSessionStore(storage, RUNTIME_ID) + paths = ClaudeSessionPaths.for_runtime(runtime_dir, RUNTIME_ID) + if workspace is not None: + paths = replace(paths, workspace=workspace) + try: + yield ( + UiPathClaudeSDKRuntime( + agent=_agent(base_url, output_schema), + session_store=store, + session_paths=paths, + runtime_id=RUNTIME_ID, + entrypoint="agent", + ), + store, + ) + finally: + await storage.dispose() + + +def _output(result: UiPathRuntimeResult) -> dict[str, Any]: + assert isinstance(result.output, dict) + return result.output + + +def _transcript(paths: ClaudeSessionPaths, session_id: str) -> Path: + matches = list((paths.config_dir / "projects").rglob(f"{session_id}.jsonl")) + assert len(matches) == 1, f"Expected exactly one transcript, found {matches}." + return matches[0] + + +# --- The contract ----------------------------------------------------------- + + +async def test_a_deferred_call_suspends_and_a_fresh_process_finishes_it( + tmp_path: Path, upstream: FakeAnthropic, tool_body: ToolBodyLog +) -> None: + runtime_dir = tmp_path / "__uipath" + paths = ClaudeSessionPaths.for_runtime(runtime_dir, RUNTIME_ID) + + async with _process(runtime_dir, upstream.base_url) as (runtime, store): + suspended = await runtime.execute({"input": PROMPT}) + session_id = await store.get_session_id() + pending = await store.get_pending_suspend() + + assert suspended.status == UiPathRuntimeStatus.SUSPENDED + assert list(_output(suspended).values()) == [QUESTION] + + # The body runs up to interrupt() and no further, and the model is told + # nothing: the call is parked, not answered. + assert tool_body.started == [{"question": QUESTION}] + assert tool_body.returned == [] + assert upstream.tool_results == [] + + assert pending is not None + assert pending.tool_name == INTERRUPT_TOOL + assert pending.interrupt_id in _output(suspended) + assert session_id is not None + + transcript = _transcript(paths, session_id) + assert transcript.parent.name.endswith(paths.workspace.name) + assert DEFER_MARKER in transcript.read_text() + + async with _process(runtime_dir, upstream.base_url) as (resumed, store): + result = await resumed.execute( + {pending.interrupt_id: HUMAN_ANSWER}, + UiPathExecuteOptions(resume=True), + ) + assert await store.get_pending_suspend() is None + assert await store.get_session_id() == session_id + + # The fresh process replays the body from the top with the original + # arguments, and this time interrupt() returns instead of raising. + assert tool_body.started == [{"question": QUESTION}, {"question": QUESTION}] + assert tool_body.returned == [HUMAN_ANSWER] + + delivered = upstream.tool_results + assert len(delivered) == 1 + assert delivered[0]["tool_use_id"] == pending.tool_use_id + assert _tool_result_text(delivered[0]) == HUMAN_ANSWER + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert _output(result) == {"result": f"{ANSWER_PREFIX}{HUMAN_ANSWER}"} + + +async def test_a_structured_output_agent_still_suspends( + tmp_path: Path, upstream: FakeAnthropic, tool_body: ToolBodyLog +) -> None: + """A deferred turn carries no structured output, and must not be validated. + + The suspend branch has to come before the output path: an agent declaring an + output schema would otherwise report every suspension as a schema failure. + """ + runtime_dir = tmp_path / "__uipath" + + async with _process(runtime_dir, upstream.base_url, Approval) as (runtime, store): + suspended = await runtime.execute({"input": PROMPT}) + pending = await store.get_pending_suspend() + + assert suspended.status == UiPathRuntimeStatus.SUSPENDED + assert list(_output(suspended).values()) == [QUESTION] + assert pending is not None + assert tool_body.returned == [] + + +async def test_a_resumed_job_given_a_fresh_runtime_directory_still_continues( + tmp_path: Path, upstream: FakeAnthropic, tool_body: ToolBodyLog +) -> None: + """The deployed shape: state carried across, directory not. + + A real job suspended under ``.job-data/42d7392e-.../__uipath`` and resumed + under ``.job-data/bad7abe7-.../__uipath``. The CLI's transcript lives under + a directory named for the working directory, so a workspace inside the + runtime directory encoded to a different name on the second pass and the + session was reported as not found. + """ + state_db = tmp_path / "carried" / "state.db" + state_db.parent.mkdir(parents=True) + suspend_dir = tmp_path / "job-data" / "42d7392e" / "__uipath" + resume_dir = tmp_path / "job-data" / "bad7abe7" / "__uipath" + + async with _process(suspend_dir, upstream.base_url, state_db=state_db) as ( + runtime, + store, + ): + suspended = await runtime.execute({"input": PROMPT}) + pending = await store.get_pending_suspend() + session_id = await store.get_session_id() + transcript = await store.get_transcript() + + assert suspended.status == UiPathRuntimeStatus.SUSPENDED + assert pending is not None and session_id is not None + assert transcript is not None, "The transcript was not carried into storage." + assert transcript.session_id == session_id + assert DEFER_MARKER in transcript.content + + # Nothing of the first pass survives on disk, exactly as on the platform. + shutil.rmtree(suspend_dir) + + async with _process(resume_dir, upstream.base_url, state_db=state_db) as ( + resumed, + store, + ): + result = await resumed.execute( + {pending.interrupt_id: HUMAN_ANSWER}, + UiPathExecuteOptions(resume=True), + ) + assert await store.get_session_id() == session_id, ( + "The CLI could not find the session and started a new one." + ) + + assert tool_body.returned == [HUMAN_ANSWER] + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert _output(result) == {"result": f"{ANSWER_PREFIX}{HUMAN_ANSWER}"} + + +async def test_a_resume_in_a_different_working_directory_finds_its_session( + tmp_path: Path, upstream: FakeAnthropic, tool_body: ToolBodyLog +) -> None: + """The managed-workspace shape: a fresh working directory per execution. + + ``uipath`` creates a managed workspace with ``tempfile.mkdtemp``, so a + resumed execution gets a directory it has never seen. The CLI names its + transcript directory after that path, so the name is derived rather than + remembered. This is what pins that derivation: the real CLI, not our idea + of its encoding, decides whether the session is found. + """ + state_db = tmp_path / "carried" / "state.db" + state_db.parent.mkdir(parents=True) + first = tmp_path / "uipath-workspace-aaaa" + second = tmp_path / "uipath-workspace-zzzz" + + async with _process( + tmp_path / "run-1", upstream.base_url, state_db=state_db, workspace=first + ) as (runtime, store): + suspended = await runtime.execute({"input": PROMPT}) + pending = await store.get_pending_suspend() + session_id = await store.get_session_id() + record = await store.get_transcript() + + assert suspended.status == UiPathRuntimeStatus.SUSPENDED + assert pending is not None and session_id is not None + assert record is not None + assert record.project_dir == encode_project_dir(first) + + async with _process( + tmp_path / "run-2", upstream.base_url, state_db=state_db, workspace=second + ) as (resumed, store): + result = await resumed.execute( + {pending.interrupt_id: HUMAN_ANSWER}, + UiPathExecuteOptions(resume=True), + ) + assert await store.get_session_id() == session_id, ( + "The CLI could not find the session under the new working directory." + ) + + assert tool_body.returned == [HUMAN_ANSWER] + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert _output(result) == {"result": f"{ANSWER_PREFIX}{HUMAN_ANSWER}"} diff --git a/packages/uipath-claude-sdk/tests/test_factory.py b/packages/uipath-claude-sdk/tests/test_factory.py new file mode 100644 index 00000000..cb0bae1a --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_factory.py @@ -0,0 +1,142 @@ +"""Tests for the runtime factory and registration.""" + +from __future__ import annotations + +import json + +import pytest +from uipath.runtime import ( + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeFactoryRegistry, +) + +from uipath_claude_sdk.runtime import register_runtime_factory +from uipath_claude_sdk.runtime.conversational_runtime import ( + UiPathClaudeSDKConversationalRuntime, +) +from uipath_claude_sdk.runtime.errors import UiPathClaudeSDKRuntimeError +from uipath_claude_sdk.runtime.factory import UiPathClaudeSDKRuntimeFactory +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_paths import ClaudeSessionPaths + +_MAIN_PY = ( + "from claude_agent_sdk import ClaudeAgentOptions\n" + "from uipath_claude_sdk import ClaudeAgent\n" + "agent = ClaudeAgent(options=ClaudeAgentOptions(model='claude-sonnet-4-5'))\n" +) + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text(_MAIN_PY) + (tmp_path / "claude.json").write_text( + json.dumps({"agents": {"agent": "main.py:agent"}}) + ) + return tmp_path + + +def _make_factory(tmp_path) -> UiPathClaudeSDKRuntimeFactory: + return UiPathClaudeSDKRuntimeFactory( + context=UiPathRuntimeContext(runtime_dir=str(tmp_path / "__uipath")) + ) + + +def test_discover_entrypoints(project): + factory = _make_factory(project) + assert factory.discover_entrypoints() == ["agent"] + + +async def test_new_runtime_standard(project): + factory = _make_factory(project) + runtime = await factory.new_runtime(entrypoint="agent", runtime_id="rt-1") + assert isinstance(runtime, UiPathResumableRuntime) + assert isinstance(runtime.delegate, UiPathClaudeSDKRuntime) + assert not isinstance(runtime.delegate, UiPathClaudeSDKConversationalRuntime) + await factory.dispose() + + +async def test_new_runtime_conversational_from_uipath_json(project): + (project / "uipath.json").write_text( + json.dumps({"runtimeOptions": {"isConversational": True}}) + ) + factory = _make_factory(project) + runtime = await factory.new_runtime(entrypoint="agent", runtime_id="rt-1") + assert isinstance(runtime, UiPathResumableRuntime) + assert isinstance(runtime.delegate, UiPathClaudeSDKConversationalRuntime) + await factory.dispose() + + +async def test_new_runtime_conversational_from_conversation_id(project): + factory = UiPathClaudeSDKRuntimeFactory( + context=UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), conversation_id="conv-1" + ) + ) + runtime = await factory.new_runtime(entrypoint="agent", runtime_id="rt-1") + assert isinstance(runtime, UiPathResumableRuntime) + assert isinstance(runtime.delegate, UiPathClaudeSDKConversationalRuntime) + await factory.dispose() + + +async def test_unknown_entrypoint(project): + factory = _make_factory(project) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="not found. Available"): + await factory.new_runtime(entrypoint="missing", runtime_id="rt-1") + await factory.dispose() + + +async def test_get_settings(project): + factory = _make_factory(project) + settings = await factory.get_settings() + assert settings is not None + assert settings.agent_framework == "claude-agent-sdk" + assert settings.agent_type == "uipath_coded" + await factory.dispose() + + +def test_register_runtime_factory(): + register_runtime_factory() + factories = UiPathRuntimeFactoryRegistry.get_all() + assert factories.get("claude") == "claude.json" + + +class TestWorkspaceLocation: + """Where the agent runs, which decides what the CLI can find. + + The Claude CLI searches upwards from its working directory for ``.claude``, + so an agent whose working directory sits beneath the directory it was + loaded from gets the skills and project settings packaged alongside it with + nothing copied. Verified against the real CLI: a marker skill in + ``proj/.claude/skills`` is visible from ``proj`` and from ``proj/sub``, and + not from an unrelated directory. + """ + + def test_the_workspace_is_beneath_the_directory_the_agent_came_from(self, tmp_path): + project = tmp_path / "package" / "0.0.1" + project.mkdir(parents=True) + paths = ClaudeSessionPaths.for_runtime( + tmp_path / "__uipath", "job-1", project_dir=project + ) + + assert paths.workspace.is_relative_to(project) + assert paths.workspace.name == "job-1" + + def test_two_runs_of_one_package_do_not_share_a_workspace(self, tmp_path): + """A shared package directory must not become a shared scratch space.""" + project = tmp_path / "package" / "0.0.1" + first = ClaudeSessionPaths.for_runtime( + tmp_path / "a", "job-1", project_dir=project + ) + second = ClaudeSessionPaths.for_runtime( + tmp_path / "b", "job-2", project_dir=project + ) + + assert first.workspace != second.workspace + + def test_without_a_project_directory_it_falls_back_to_the_runtime_dir( + self, tmp_path + ): + paths = ClaudeSessionPaths.for_runtime(tmp_path / "__uipath", "job-1") + assert paths.workspace.is_relative_to(tmp_path / "__uipath") diff --git a/packages/uipath-claude-sdk/tests/test_gateway.py b/packages/uipath-claude-sdk/tests/test_gateway.py new file mode 100644 index 00000000..31c426b2 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_gateway.py @@ -0,0 +1,568 @@ +"""Tests for the gateway routing shim. + +The router is exercised end to end against a fake UiPath gateway, over a real +``UiPathHttpxAsyncClient`` upstream leg, so header injection and URL freezing +are covered rather than mocked away. +""" + +from __future__ import annotations + +import base64 +import binascii +import functools +import gzip +import json +import socket +import struct +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest +from aiohttp import web +from uipath.llm_client import UiPathHttpxAsyncClient + +from uipath_claude_sdk.runtime.gateway import GatewayShim, upstream +from uipath_claude_sdk.runtime.gateway.catalog import ( + ModelCatalog, + alias_candidates, +) +from uipath_claude_sdk.runtime.gateway.errors import ( + ModelNotInCatalogError, + UnsupportedApiFlavorError, + to_anthropic_error, +) +from uipath_claude_sdk.runtime.gateway.records import UsageTee +from uipath_claude_sdk.runtime.gateway.strategies import ( + AnthropicMessagesStrategy, + BedrockInvokeStrategy, + select_strategy, +) + +SONNET = "anthropic.claude-sonnet-4-5-20250929-v1:0" +SONNET_NAME = "claude-sonnet-4-5" +"""What a developer writes. Discovery picks the route, never the name, so this +is what goes upstream, exactly as uipath-langchain sends it.""" +HAIKU = "anthropic.claude-haiku-4-5-20251001-v1:0" +OPUS = "anthropic.claude-opus-4-1-20250805-v1:0" + +DISCOVERY: list[dict[str, Any]] = [ + {"modelName": "gpt-5.2-2025-12-11", "vendor": "OpenAi", "apiFlavor": None}, + {"modelName": SONNET, "vendor": "AwsBedrock", "apiFlavor": "AnthropicMessages"}, + {"modelName": HAIKU, "vendor": "AwsBedrock", "apiFlavor": "AnthropicMessages"}, + {"modelName": OPUS, "vendor": "AwsBedrock", "apiFlavor": None}, +] + +SSE = ( + b'event: message_start\ndata: {"type":"message_start","message":{"usage":' + b'{"input_tokens":11,"cache_read_input_tokens":3}}}\n\n' + b'event: content_block_delta\ndata: {"type":"content_block_delta"}\n\n' + b'event: message_delta\ndata: {"type":"message_delta","delta":' + b'{"stop_reason":"end_turn"},"usage":{"output_tokens":42}}\n\n' +) + + +def eventstream_frame(event: dict[str, Any]) -> bytes: + """Encode one AWS Event Stream frame carrying a base64 Anthropic event.""" + inner = json.dumps(event).encode() + payload = json.dumps({"bytes": base64.b64encode(inner).decode()}).encode() + prelude = struct.pack(">II", 16 + len(payload), 0) + frame = prelude + struct.pack(">I", binascii.crc32(prelude)) + payload + return frame + struct.pack(">I", binascii.crc32(frame)) + + +# --- fixtures -------------------------------------------------------------- + + +@pytest.fixture +async def gateway() -> AsyncIterator[dict[str, Any]]: + """A fake UiPath gateway that records what the shim forwarded.""" + seen: list[dict[str, Any]] = [] + + async def handler(request: web.Request) -> web.StreamResponse: + raw = await request.read() + body = json.loads(raw) + seen.append({"headers": dict(request.headers), "body": body, "raw": raw}) + match body.get("mode"): + case "error": + return web.json_response( + {"error": {"message": "denied", "correlationId": "corr-9"}}, + status=403, + ) + case "ratelimit": + return web.json_response( + {"error": {"message": "too many requests"}}, + status=429, + headers={"anthropic-ratelimit-requests-remaining": "0"}, + ) + case "unary": + return web.json_response( + {"usage": {"input_tokens": 5, "output_tokens": 6}} + ) + case "midstream": + partial = web.StreamResponse( + headers={"Content-Type": "text/event-stream"} + ) + await partial.prepare(request) + await partial.write(b"event: ping\ndata: {}\n\n") + request.transport.abort() # type: ignore[union-attr] + return partial + response = web.StreamResponse( + headers={ + "Content-Type": "text/event-stream", + "x-uipath-correlation-id": "c1", + } + ) + await response.prepare(request) + await response.write(SSE) + await response.write_eof() + return response + + app = web.Application() + app.router.add_post("/{tail:.*}", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + yield {"base_url": f"http://127.0.0.1:{port}", "seen": seen} + await runner.cleanup() + + +@pytest.fixture +async def shim( + gateway: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[GatewayShim]: + """A started shim whose upstream leg does not retry. + + The LLM client retries 429 and 5xx with a multi-second backoff of its own, + which no unit test should sit through. + """ + monkeypatch.setattr( + upstream, + "UiPathHttpxAsyncClient", + functools.partial(UiPathHttpxAsyncClient, max_retries=0), + ) + + settings = MagicMock() + settings.get_available_models.return_value = DISCOVERY + settings.build_base_url.return_value = gateway["base_url"] + settings.build_auth_headers.return_value = {} + settings.build_auth_pipeline.return_value = None + + records: list[Any] = [] + started = GatewayShim( + MagicMock(model="claude-sonnet-4-5"), + settings=settings, + on_call=records.append, + ) + started.records = records # type: ignore[attr-defined] + await started.start() + yield started + await started.stop() + + +@pytest.fixture +def client(shim: GatewayShim) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=shim.base_url, headers={"x-api-key": shim.api_key} + ) + + +# --- catalog --------------------------------------------------------------- + + +class TestCatalog: + def test_exact_id(self): + assert ModelCatalog(DISCOVERY).resolve(SONNET).model_id == SONNET + + @pytest.mark.parametrize( + "requested", + [ + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929-v1:0", + "Claude-Sonnet-4-5", + "anthropic.claude-sonnet-4-5", + ], + ) + def test_aliases_reach_the_tenant_id(self, requested): + assert ModelCatalog(DISCOVERY).resolve(requested).model_id == SONNET + + def test_family_word_picks_the_newest(self): + catalog = ModelCatalog(DISCOVERY) + assert catalog.resolve("haiku").model_id == HAIKU + assert catalog.resolve("opus").model_id == OPUS + + def test_vertex_style_alias(self): + assert "claude-sonnet-4-5" in alias_candidates("claude-sonnet-4-5@20250929") + + def test_discovered_flavor_wins(self): + assert ModelCatalog(DISCOVERY).resolve("claude-sonnet-4-5").api_flavor == ( + "AnthropicMessages" + ) + + def test_bedrock_without_a_flavor_defaults_to_invoke(self): + resolved = ModelCatalog(DISCOVERY).resolve("opus") + assert (resolved.vendor_type, resolved.api_flavor) == ("awsbedrock", "invoke") + + def test_unknown_model_lists_what_the_tenant_has(self): + with pytest.raises(ModelNotInCatalogError) as exc: + ModelCatalog(DISCOVERY).resolve("claude-nonesuch") + assert SONNET in str(exc.value) + + def test_family_falls_back_when_absent(self): + catalog = ModelCatalog([DISCOVERY[1]]) + assert catalog.resolve_set("claude-sonnet-4-5").opus.model_id == SONNET + + +# --- strategies ------------------------------------------------------------ + + +class TestStrategies: + def test_selection_follows_discovery(self): + catalog = ModelCatalog(DISCOVERY) + assert isinstance( + select_strategy(catalog.resolve("claude-sonnet-4-5")), + AnthropicMessagesStrategy, + ) + assert isinstance( + select_strategy(catalog.resolve("opus")), BedrockInvokeStrategy + ) + + def test_unroutable_flavor_is_rejected(self): + catalog = ModelCatalog(DISCOVERY) + with pytest.raises(UnsupportedApiFlavorError): + select_strategy(catalog.resolve("gpt-5.2-2025-12-11")) + + def test_anthropic_messages_sends_the_name_the_developer_wrote(self): + resolved = ModelCatalog(DISCOVERY).resolve("claude-sonnet-4-5") + payload = { + "model": "claude-sonnet-4-5", + "context_management": {"edits": []}, + "system": [{"type": "text", "text": "hi"}], + } + body = json.loads( + AnthropicMessagesStrategy().build_request(payload, {}, resolved).body + ) + assert body["model"] == SONNET_NAME + assert body["system"] == [{"type": "text", "text": "hi"}] + assert "context_management" not in body + + def test_no_phantom_system_key(self): + resolved = ModelCatalog(DISCOVERY).resolve("claude-sonnet-4-5") + body = json.loads( + AnthropicMessagesStrategy().build_request({}, {}, resolved).body + ) + assert "system" not in body + + def test_invoke_moves_transport_fields_out_of_the_body(self): + resolved = ModelCatalog(DISCOVERY).resolve("opus") + payload = {"model": "x", "stream": True, "system": [{"type": "text"}]} + body = json.loads( + BedrockInvokeStrategy().build_request(payload, {}, resolved).body + ) + assert "model" not in body + assert "stream" not in body + assert body["anthropic_version"] == "bedrock-2023-05-31" + assert body["system"] == [{"type": "text"}] + + async def test_invoke_reemits_eventstream_frames_as_sse(self): + upstream = httpx.Response( + 200, + headers={"content-type": "application/vnd.amazon.eventstream"}, + content=eventstream_frame({"type": "message_start", "message": {}}), + ) + out = b"".join( + [c async for c in BedrockInvokeStrategy().stream_response(upstream)] + ) + assert out.startswith(b"event: message_start\ndata: ") + + +# --- errors ---------------------------------------------------------------- + + +class TestErrors: + @pytest.mark.parametrize( + ("status", "expected"), + [ + (400, "invalid_request_error"), + (401, "authentication_error"), + (403, "permission_error"), + (404, "not_found_error"), + (429, "rate_limit_error"), + (503, "overloaded_error"), + (529, "overloaded_error"), + (500, "api_error"), + ], + ) + def test_status_mapping(self, status, expected): + assert to_anthropic_error(status, b"{}")["error"]["type"] == expected + + def test_gateway_message_and_correlation_survive(self): + payload = to_anthropic_error( + 429, b'{"error":{"message":"slow down","correlationId":"abc"}}' + ) + assert payload["error"]["message"] == "slow down" + assert payload["request_id"] == "abc" + + def test_non_json_body_is_kept(self): + payload = to_anthropic_error(502, b"gateway down") + assert "gateway down" in payload["error"]["message"] + + +# --- usage tee ------------------------------------------------------------- + + +class TestUsageTee: + def test_counts_survive_arbitrary_chunk_boundaries(self): + tee = UsageTee() + for start in range(0, len(SSE), 7): + tee.feed(SSE[start : start + 7]) + assert (tee.input_tokens, tee.output_tokens) == (11, 42) + assert tee.cache_read_input_tokens == 3 + assert tee.stop_reason == "end_turn" + + def test_non_streaming_body(self): + tee = UsageTee() + tee.feed_message({"usage": {"input_tokens": 1, "output_tokens": 2}}) + assert (tee.input_tokens, tee.output_tokens) == (1, 2) + + +# --- environment ----------------------------------------------------------- + + +class TestEnv: + def test_every_auxiliary_model_is_pinned_to_a_tenant_id(self, shim): + env = shim.build_env() + assert env["ANTHROPIC_BASE_URL"] == shim.base_url + assert env["ANTHROPIC_API_KEY"] == shim.api_key + assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == HAIKU + assert env["ANTHROPIC_DEFAULT_SONNET_MODEL"] == SONNET + assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == OPUS + assert env["ANTHROPIC_SMALL_FAST_MODEL"] == HAIKU + assert env["CLAUDE_CODE_BG_CLASSIFIER_MODEL"] == HAIKU + assert env["CLAUDE_CODE_SUBAGENT_MODEL"] == SONNET_NAME + assert env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert env["CLAUDE_CODE_GZIP_REQUEST_BODIES"] == "0" + + async def test_start_is_idempotent(self, shim): + router, port = shim._router, shim._router.port + await shim.start() + assert shim._router is router + assert shim._router.port == port + assert shim.base_url == f"http://127.0.0.1:{port}" + + async def test_a_repeated_start_leaves_exactly_one_runner_to_stop(self, shim): + """A second bind would leak a listener that a single stop cannot release.""" + runner = shim._router._runner + await shim.start() + await shim.start() + assert shim._router._runner is runner + port = shim._router.port + + await shim.stop() + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(2.0) + assert probe.connect_ex(("127.0.0.1", port)) != 0 + + +# --- router ---------------------------------------------------------------- + + +class TestRouter: + async def test_probe_requires_the_run_secret(self, shim, client): + async with httpx.AsyncClient(base_url=shim.base_url) as anonymous: + assert (await anonymous.request("HEAD", "/api/hello")).status_code == 401 + async with client: + assert (await client.request("HEAD", "/api/hello")).status_code == 200 + + async def test_bearer_is_accepted(self, shim, client): + async with client: + response = await client.request( + "HEAD", + "/api/hello", + headers={"x-api-key": "", "Authorization": f"Bearer {shim.api_key}"}, + ) + assert response.status_code == 200 + + async def test_models_lists_the_tenant_catalog(self, shim, client): + async with client: + body = (await client.get("/v1/models")).json() + assert {m["id"] for m in body["data"]} == {m["modelName"] for m in DISCOVERY} + + async def test_count_tokens_is_honest_about_being_unsupported(self, shim, client): + async with client: + response = await client.post("/v1/messages/count_tokens", json={}) + assert response.status_code == 404 + assert response.json()["error"]["type"] == "not_found_error" + + async def test_stream_is_copied_verbatim(self, shim, client, gateway): + async with client: + response = await client.post( + "/v1/messages", + headers={ + "anthropic-beta": "context-management-2025-06-27", + "anthropic-version": "2023-06-01", + }, + json={ + "model": "claude-sonnet-4-5", + "stream": True, + "output_config": {"x": 1}, + }, + ) + assert response.status_code == 200 + assert response.content == SSE + sent = gateway["seen"][-1] + assert sent["body"]["model"] == SONNET_NAME + assert sent["body"]["output_config"] == {"x": 1} + assert sent["headers"]["anthropic-beta"] == "context-management-2025-06-27" + assert sent["headers"]["anthropic-version"] == "2023-06-01" + assert sent["headers"]["X-UiPath-Streaming-Enabled"] == "true" + + async def test_forwarded_body_is_verbatim_apart_from_pruned_fields( + self, shim, client, gateway + ): + """Everything the CLI sends must survive byte for byte, model included.""" + payload = { + "model": "claude-sonnet-4-5", + "stream": True, + "max_tokens": 8192, + "system": [ + { + "type": "text", + "text": "be brief", + "cache_control": {"type": "ephemeral"}, + } + ], + "messages": [{"role": "user", "content": "hello"}], + "context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]}, + "metadata": {"user_id": "u-1"}, + "tool_choice": {"type": "auto"}, + } + async with client: + response = await client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + expected = {k: v for k, v in payload.items() if k != "context_management"} + assert gateway["seen"][-1]["raw"] == json.dumps(expected).encode() + + async def test_a_wrong_run_secret_is_rejected(self, shim): + async with httpx.AsyncClient(base_url=shim.base_url) as wrong: + response = await wrong.post( + "/v1/messages", + headers={"x-api-key": "x" * len(shim.api_key)}, + json={"model": "claude-sonnet-4-5", "stream": True}, + ) + assert response.status_code == 401 + assert response.json()["error"]["type"] == "authentication_error" + + async def test_rate_limited_upstream_becomes_an_anthropic_rate_limit_error( + self, shim, client + ): + async with client: + response = await client.post( + "/v1/messages", + json={"model": "claude-sonnet-4-5", "mode": "ratelimit"}, + ) + assert response.status_code == 429 + body = response.json() + assert body["type"] == "error" + assert body["error"]["type"] == "rate_limit_error" + assert body["error"]["message"] == "too many requests" + record = shim.records[-1] + assert record.status == 429 + assert record.response_headers["anthropic-ratelimit-requests-remaining"] == "0" + + async def test_gzipped_request_body_is_accepted(self, shim, client, gateway): + payload = json.dumps({"model": "claude-sonnet-4-5", "stream": True}).encode() + async with client: + response = await client.post( + "/v1/messages", + headers={ + "content-encoding": "gzip", + "content-type": "application/json", + }, + content=gzip.compress(payload), + ) + assert response.status_code == 200 + assert gateway["seen"][-1]["body"]["model"] == SONNET_NAME + + async def test_record_carries_tokens_and_uipath_headers(self, shim, client): + async with client: + await client.post( + "/v1/messages", json={"model": "claude-sonnet-4-5", "stream": True} + ) + record = shim.records[-1] + assert (record.input_tokens, record.output_tokens) == (11, 42) + assert record.cache_read_input_tokens == 3 + assert record.stop_reason == "end_turn" + assert record.resolved_model == SONNET + assert record.requested_model == "claude-sonnet-4-5" + assert record.response_headers["x-uipath-correlation-id"] == "c1" + + async def test_non_streaming_call(self, shim, client, gateway): + async with client: + response = await client.post( + "/v1/messages", json={"model": "claude-sonnet-4-5", "mode": "unary"} + ) + assert response.status_code == 200 + assert gateway["seen"][-1]["headers"]["X-UiPath-Streaming-Enabled"] == "false" + record = shim.records[-1] + assert (record.input_tokens, record.output_tokens) == (5, 6) + + async def test_upstream_error_is_reshaped(self, shim, client): + async with client: + response = await client.post( + "/v1/messages", + json={"model": "claude-sonnet-4-5", "stream": True, "mode": "error"}, + ) + assert response.status_code == 403 + body = response.json() + assert body["error"]["type"] == "permission_error" + assert body["error"]["message"] == "denied" + assert body["request_id"] == "corr-9" + + async def test_mid_stream_failure_becomes_an_sse_error_frame(self, shim, client): + """A clean read proves the stream was terminated, not truncated. + + Whatever the upstream got out before it died is forwarded verbatim, but + the abort resets the connection and an unread frame can be discarded + with it, so only the trailing error frame is guaranteed. + """ + async with client: + response = await client.post( + "/v1/messages", + json={ + "model": "claude-sonnet-4-5", + "stream": True, + "mode": "midstream", + }, + ) + assert response.status_code == 200 + + frames = [frame for frame in response.content.split(b"\n\n") if frame] + assert set(frames[:-1]) <= {b"event: ping\ndata: {}"} + assert frames[-1].startswith(b"event: error\ndata: ") + payload = json.loads(frames[-1].split(b"data: ", 1)[1]) + assert payload["type"] == "error" + assert payload["error"]["type"] == "api_error" + assert shim.records[-1].status == 502 + + async def test_unknown_model_404s_with_the_tenant_list(self, shim, client): + async with client: + response = await client.post("/v1/messages", json={"model": "gpt-9"}) + assert response.status_code == 404 + assert SONNET in response.json()["error"]["message"] + + async def test_unroutable_model_is_rejected_before_forwarding(self, shim, client): + async with client: + response = await client.post( + "/v1/messages", json={"model": "gpt-5.2-2025-12-11"} + ) + assert response.status_code == 400 + + async def test_malformed_body(self, shim, client): + async with client: + response = await client.post("/v1/messages", content=b"not json") + assert response.status_code == 400 diff --git a/packages/uipath-claude-sdk/tests/test_instrumentation.py b/packages/uipath-claude-sdk/tests/test_instrumentation.py new file mode 100644 index 00000000..c16c9c01 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_instrumentation.py @@ -0,0 +1,244 @@ +"""The run loop has to read a turn the way the tracing instrumentor expects. + +``openinference-instrumentation-claude-agent-sdk`` wraps ``connect``, ``query`` +and ``receive_response``. It does not wrap ``receive_messages``, and its tool +tracker only records while a ``receive_response`` span is open, so a loop that +reads the underlying stream directly produces no agent span, no tool spans, and +no parent for the gateway's model spans to nest under. + +Nothing else in the suite notices that. The telemetry tests assert what the +attribute stripper does to spans shaped like the instrumentor's, which they build +by hand, so they keep passing when the instrumentor stops being reached at all. +These two facts compose into "the spans arrive", and each is cheap to hold: +upstream wraps that one method, and this runtime calls it once per turn. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + TaskNotificationMessage, + TaskStartedMessage, + TextBlock, +) +from claude_agent_sdk.client import ClaudeSDKClient +from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from tests.conftest import ( + FakeClaudeSDKClient, + make_result_message, + make_session_paths, +) +from uipath_claude_sdk.interrupts import ( + InterruptToolBinding, + SuspendChannel, + _identifying_inputs_only, + run_tool_body, +) +from uipath_claude_sdk.runtime.runtime import ( + UiPathClaudeSDKRuntime, + _resume_shape_only, +) + +WRAPPED = ("connect", "query", "receive_response") +NOT_WRAPPED = ("receive_messages",) + + +@pytest.fixture +def exporter() -> InMemorySpanExporter: + return InMemorySpanExporter() + + +def test_the_instrumentor_wraps_receive_response_and_not_receive_messages(): + """A version bump that moves this surface has to fail loudly here. + + The whole reason the run loop reads a turn through the convenience method is + that this is where the instrumentation lives. If a release starts wrapping + ``receive_messages`` too, the constraint is gone and the loop is free again. + """ + for name in WRAPPED + NOT_WRAPPED: + assert not hasattr(getattr(ClaudeSDKClient, name), "__wrapped__") + + ClaudeAgentSDKInstrumentor().instrument() + + for name in WRAPPED: + assert hasattr(getattr(ClaudeSDKClient, name), "__wrapped__"), ( + f"{name} is no longer instrumented" + ) + for name in NOT_WRAPPED: + assert not hasattr(getattr(ClaudeSDKClient, name), "__wrapped__"), ( + f"{name} is now instrumented, so the run loop no longer has to avoid it" + ) + + +class _RecordingClient(FakeClaudeSDKClient): + """Records which stream method each turn was read through.""" + + reads: list[str] = [] + + async def receive_response(self): + type(self).reads.append("receive_response") + async for message in super().receive_response(): + yield message + + async def receive_messages(self): + type(self).reads.append("receive_messages") + async for message in super().receive_messages(): + yield message + + +@pytest.fixture +def recording_client(fake_client, monkeypatch: pytest.MonkeyPatch): + _RecordingClient.reads = [] + monkeypatch.setattr( + "uipath_claude_sdk.runtime.runtime.ClaudeSDKClient", _RecordingClient + ) + return _RecordingClient + + +async def _run(agent: Any, tmp_path: Any, session_store: Any) -> None: + runtime = UiPathClaudeSDKRuntime( + agent=agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path, "rt-1"), + runtime_id="rt-1", + ) + await runtime.execute({"input": "which accounts are open?"}) + + +async def test_a_turn_is_read_through_receive_response( + recording_client, plain_agent, tmp_path, session_store +): + recording_client.scripted_messages = [make_result_message(result="done")] + + await _run(plain_agent, tmp_path, session_store) + + assert recording_client.reads[0] == "receive_response" + assert recording_client.reads.count("receive_response") == 1 + + +class TestTracedInternalsKeepPayloadsOut: + """``@traced`` records inputs and outputs on the span by default. + + The internals it is applied to handle a suspend value, a resume payload and + a transcript, which are the customer's data and have no business on a span. + Each of those spans therefore carries a processor, and these tests are what + hold the processors to naming the work without copying it. + """ + + def test_a_suspension_is_named_without_its_value(self): + kept = _identifying_inputs_only( + { + "tool_name": "review", + "tool_use_id": "toolu_1", + "value": {"data": {"AgentOutput": "customer's quote"}}, + } + ) + + assert kept == {"tool_name": "review", "tool_use_id": "toolu_1"} + + def test_a_resume_reports_only_that_it_is_resuming(self): + kept = _resume_shape_only( + {"resuming": True, "input": {"answer": "Acme Corporation"}} + ) + + assert kept == {"resuming": True} + + +class TestToolBodyTracing: + """Where a span opened by a developer's tool body ends up. + + The client's tasks inherit their context from before the run began, so a + connection lookup or an HTTP call inside a tool would otherwise be parented + to whatever was current then and appear beside the turn instead of within + it. The runtime hands the turn to the channel, and the body runs under it. + """ + + @staticmethod + def _turn(provider: TracerProvider): + turn = provider.get_tracer("test").start_span("turn") + return turn, trace_api.set_span_in_context(turn) + + async def _run_body(self, channel: SuspendChannel, body: Any) -> None: + await run_tool_body( + channel, + InterruptToolBinding(token="tok", body=body), + {}, + "retrieve", + "toolu_1", + ) + + async def test_a_span_from_a_tool_body_belongs_to_the_turn(self, exporter): + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + turn, turn_context = self._turn(provider) + channel = SuspendChannel() + channel.adopt_turn_parent(turn_context) + + async def body(args: dict[str, Any]) -> dict[str, Any]: + provider.get_tracer("test").start_span("connections_retrieve").end() + return {"content": []} + + await self._run_body(channel, body) + turn.end() + + emitted = {span.name: span for span in exporter.get_finished_spans()} + parent = emitted["connections_retrieve"].parent + assert parent is not None + assert parent.span_id == turn.get_span_context().span_id + + async def test_a_tool_body_still_runs_before_any_turn_is_recorded(self): + channel = SuspendChannel() + ran = [] + + async def body(args: dict[str, Any]) -> dict[str, Any]: + ran.append(True) + return {"content": []} + + await self._run_body(channel, body) + + assert ran == [True] + + +async def test_a_background_turn_is_read_through_receive_response_too( + recording_client, plain_agent, tmp_path, session_store +): + """A turn held open for background work still gets its own agent span.""" + recording_client.scripted_messages = [ + TaskStartedMessage( + subtype="task_started", + data={}, + task_id="agent-1", + description="research", + uuid="task-start-1", + session_id="session-1", + task_type="local_agent", + ), + make_result_message(result="Task launched."), + TaskNotificationMessage( + subtype="task_notification", + data={}, + task_id="agent-1", + status="completed", + output_file="agent-1.output", + summary="Research complete", + uuid="task-end-1", + session_id="session-1", + ), + AssistantMessage( + content=[TextBlock(text="Final answer.")], + model="claude-sonnet-4-5", + ), + make_result_message(result="Final answer."), + ] + + await _run(plain_agent, tmp_path, session_store) + + assert recording_client.reads.count("receive_response") == 2 diff --git a/packages/uipath-claude-sdk/tests/test_interrupt_tools.py b/packages/uipath-claude-sdk/tests/test_interrupt_tools.py new file mode 100644 index 00000000..a5e4f116 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_interrupt_tools.py @@ -0,0 +1,460 @@ +"""The developer owns the tool, and UiPath only lends it a suspension. + +``uipath_tool_server`` must leave a tool's name, description and schema exactly +as written, and must not appear in an agent that never uses it. What it adds is +``interrupt``: the body runs inside the ``PreToolUse`` hook, suspends by raising +on the first pass and returns the resolved payload on the second, and the +handler delivers whatever the hook already computed instead of running twice. + +The value handed to ``interrupt`` is never touched, so these tests push real +platform models through ``UiPathResumeTriggerCreator`` to prove the trigger kind +still comes from the model type and from nothing this package does. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from claude_agent_sdk import ( + HookContext, + HookJSONOutput, + PreToolUseHookInput, + tool, +) +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + CallToolResult, + ListToolsRequest, +) +from uipath.core.triggers import UiPathResumeTriggerType +from uipath.platform.common import CreateTask, WaitJob +from uipath.platform.orchestrator.job import Job +from uipath.platform.resume_triggers import UiPathResumeTriggerCreator + +from uipath_claude_sdk.interrupts import ( + InterruptOutsideRunError, + SuspendChannel, + active_channel, + interrupt, + uipath_tool_index, + uipath_tool_server, +) +from uipath_claude_sdk.runtime.suspend import ( + TOOL_BODY_TIMEOUT_SECONDS, + build_suspend_hooks, +) + +SERVER_KEY = "tickets" +TOOL_NAME = "review" +QUALIFIED = f"mcp__{SERVER_KEY}__{TOOL_NAME}" +TOOL_USE_ID = "toolu_1" +ESCALATION = CreateTask( + title="Action Required: Review classification", + app_name="escalation_agent_app", + app_folder_path="Shared", + data={"AgentOutput": "Classified TCK-1 as billing"}, +) + + +def _text(payload: Any) -> dict[str, Any]: + return {"content": [{"type": "text", "text": str(payload)}]} + + +def _review_tool(value: Any = ESCALATION, calls: list[str] | None = None): + """The canonical developer tool: one interrupt, then the developer's own output.""" + + @tool(TOOL_NAME, "Get a human to review the classification.", {"ticket_id": str}) + async def review(args: dict[str, Any]) -> dict[str, Any]: + if calls is not None: + calls.append(args["ticket_id"]) + action_data = await interrupt(value) + return _text(f"Approved: {action_data['Answer']}") + + return review + + +async def _served(config: Any) -> list[Any]: + server = config["instance"] + for request_type, handler in server.request_handlers.items(): + if request_type.__name__ != "ListToolsRequest": + continue + result = await handler(ListToolsRequest(method="tools/list")) + return list(result.root.tools) + raise AssertionError("The server exposes no ListToolsRequest handler.") + + +async def _call(config: Any, args: dict[str, Any]) -> CallToolResult: + server = config["instance"] + for request_type, handler in server.request_handlers.items(): + if request_type.__name__ != "CallToolRequest": + continue + result = await handler( + CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name=TOOL_NAME, arguments=args), + ) + ) + return result.root + raise AssertionError("The server exposes no CallToolRequest handler.") + + +async def _gate( + channel: SuspendChannel, + config: Any, + args: dict[str, Any], + tool_name: str = QUALIFIED, + tool_use_id: str | None = TOOL_USE_ID, +) -> HookJSONOutput: + hooks = build_suspend_hooks( + channel, uipath_tool_index({SERVER_KEY: config, "other": {"type": "stdio"}}) + ) + hook_input: PreToolUseHookInput = { + "hook_event_name": "PreToolUse", + "tool_name": tool_name, + "tool_input": args, + "tool_use_id": tool_use_id or "", + "session_id": "session-1", + "transcript_path": "", + "cwd": "", + } + return await hooks["PreToolUse"][0].hooks[0]( + hook_input, tool_use_id, HookContext(signal=None) + ) + + +def _decision(output: HookJSONOutput) -> tuple[str, str]: + payload: dict[str, Any] = dict(output) + if not payload: + return "", "" + specific = payload["hookSpecificOutput"] + decision: str = specific["permissionDecision"] + return decision, specific.get("permissionDecisionReason", "") + + +def _result_text(result: CallToolResult) -> str: + return "".join(block.text for block in result.content if block.type == "text") + + +async def test_the_tool_reaches_the_model_exactly_as_it_was_written(): + """Wrapping is an implementation detail, so none of it may be visible.""" + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + assert config["type"] == "sdk" + assert config["name"] == SERVER_KEY + served = await _served(config) + assert [entry.name for entry in served] == [TOOL_NAME] + assert served[0].description == "Get a human to review the classification." + assert served[0].inputSchema["properties"]["ticket_id"] == {"type": "string"} + + +async def test_only_a_uipath_server_lands_in_the_index(): + """An agent that never called uipath_tool_server gets no UiPath wiring.""" + uipath_config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + plain = {"type": "stdio", "command": "noop"} + + assert set(uipath_tool_index({SERVER_KEY: uipath_config, "other": plain})) == { + QUALIFIED + } + assert uipath_tool_index({"other": plain}) == {} + assert uipath_tool_index(None) == {} + + +async def test_the_index_follows_the_key_the_developer_registered(): + """The CLI qualifies a tool by the mcp_servers key, not by the server's name.""" + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + assert set(uipath_tool_index({"renamed": config})) == {"mcp__renamed__review"} + + +async def test_a_tool_this_package_did_not_build_is_left_alone(): + """The hook must never interfere with a developer's own tools.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + output = await _gate(channel, config, {"command": "ls"}, tool_name="Bash") + + assert output == {} + assert channel.pending is None + + +async def test_the_hook_gets_a_timeout_long_enough_to_run_a_tool_body(): + """The body runs inside the hook, so the SDK's 60s default would cap it.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + hooks = build_suspend_hooks(channel, uipath_tool_index({SERVER_KEY: config})) + + assert hooks["PreToolUse"][0].timeout == TOOL_BODY_TIMEOUT_SECONDS + assert TOOL_BODY_TIMEOUT_SECONDS > 60 + + +async def test_the_suspend_pass_defers_and_keeps_the_value_untouched(): + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + decision, _ = await _decision_for(channel, config) + + assert decision == "defer" + assert channel.pending is not None + assert channel.pending.value is ESCALATION + assert channel.pending.tool_name == QUALIFIED + assert channel.deferrals_requested == 1 + + +async def _decision_for(channel: SuspendChannel, config: Any) -> tuple[str, str]: + return _decision(await _gate(channel, config, {"ticket_id": "TCK-1"})) + + +async def test_the_interrupt_id_is_the_tool_use_id_the_cli_will_re_issue(): + """A uuid4 of our own could not be matched to the re-issued call.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + await _decision_for(channel, config) + + assert channel.pending is not None + assert channel.pending.interrupt_id == TOOL_USE_ID + assert channel.pending.tool_use_id == TOOL_USE_ID + + +async def test_the_resume_pass_runs_the_body_through_and_the_handler_delivers_it(): + """The handler must return the hook's value rather than run the body again.""" + calls: list[str] = [] + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool(calls=calls)]) + args = {"ticket_id": "TCK-1"} + + await _gate(channel, config, args) + channel.resolve(TOOL_USE_ID, {"Answer": True}) + decision, _ = _decision(await _gate(channel, config, args)) + + assert decision == "allow" + with active_channel(channel): + result = await _call(config, args) + + assert _result_text(result) == "Approved: True" + assert calls == ["TCK-1", "TCK-1"] + assert channel.pending is None + + +async def test_a_body_that_never_interrupts_runs_once_and_allows(): + calls: list[str] = [] + + @tool(TOOL_NAME, "Look a ticket up.", {"ticket_id": str}) + async def lookup(args: dict[str, Any]) -> dict[str, Any]: + calls.append(args["ticket_id"]) + return _text(f"Ticket {args['ticket_id']} is open") + + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[lookup]) + args = {"ticket_id": "TCK-1"} + + decision, _ = _decision(await _gate(channel, config, args)) + + assert decision == "allow" + assert channel.pending is None + with active_channel(channel): + result = await _call(config, args) + + assert _result_text(result) == "Ticket TCK-1 is open" + assert calls == ["TCK-1"] + + +async def test_two_identical_calls_in_one_turn_each_run_the_body_once(): + """The model may make the same call twice, and each has its own outcome. + + A handler is handed its arguments and no ``tool_use_id``, so the stash is + keyed by the tool and its arguments. Holding one outcome per key would let + the second call overwrite the first, and the handler that found nothing + would run the body a second time: three executions for two calls, with + whatever the body writes happening three times. + """ + runs: list[str] = [] + + @tool(TOOL_NAME, "Charge a ticket.", {"ticket_id": str}) + async def charge(args: dict[str, Any]) -> dict[str, Any]: + runs.append(args["ticket_id"]) + return _text(f"charge {len(runs)}") + + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[charge]) + args = {"ticket_id": "TCK-1"} + + for tool_use_id in ("toolu_1", "toolu_2"): + decision, _ = _decision( + await _gate(channel, config, args, tool_use_id=tool_use_id) + ) + assert decision == "allow" + + with active_channel(channel): + delivered = { + _result_text(await _call(config, args)), + _result_text(await _call(config, args)), + } + + assert runs == ["TCK-1", "TCK-1"] + assert delivered == {"charge 1", "charge 2"} + + +async def test_a_body_that_raises_reaches_the_model_as_a_tool_error(): + """An exception escaping the hook would fault the job, not the turn.""" + + @tool(TOOL_NAME, "Look a ticket up.", {"ticket_id": str}) + async def lookup(args: dict[str, Any]) -> dict[str, Any]: + raise ValueError("no such ticket") + + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[lookup]) + args = {"ticket_id": "TCK-1"} + + decision, _ = _decision(await _gate(channel, config, args)) + + assert decision == "allow" + with active_channel(channel): + result = await _call(config, args) + + assert result.isError + assert "no such ticket" in _result_text(result) + + +async def test_a_second_interrupt_is_denied_while_one_is_in_flight(): + """The CLI parks both and then erases one, so the second has to be refused.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + await _gate(channel, config, {"ticket_id": "TCK-1"}) + decision, reason = _decision( + await _gate(channel, config, {"ticket_id": "TCK-2"}, tool_use_id="toolu_2") + ) + + assert decision == "deny" + assert "already in flight" in reason + assert channel.pending is not None + assert channel.pending.interrupt_id == TOOL_USE_ID + + +async def test_a_call_with_no_tool_use_id_is_denied_rather_than_parked(): + """It could not be correlated with the re-issued call on resume.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + decision, reason = _decision( + await _gate(channel, config, {"ticket_id": "TCK-1"}, tool_use_id=None) + ) + + assert decision == "deny" + assert "tool_use id" in reason + assert channel.pending is None + + +async def test_interrupt_outside_a_uipath_run_says_what_is_missing(): + """The likeliest mistake is forgetting uipath_tool_server, so name it.""" + with pytest.raises(InterruptOutsideRunError) as caught: + await interrupt(ESCALATION) + + assert "uipath_tool_server" in str(caught.value) + + +async def test_a_tool_without_a_uipath_run_around_it_just_runs(): + """The same agent has to work under a plain Claude Agent SDK run.""" + calls: list[str] = [] + + @tool(TOOL_NAME, "Look a ticket up.", {"ticket_id": str}) + async def lookup(args: dict[str, Any]) -> dict[str, Any]: + calls.append(args["ticket_id"]) + return _text("open") + + config = uipath_tool_server(SERVER_KEY, tools=[lookup]) + + result = await _call(config, {"ticket_id": "TCK-1"}) + + assert _result_text(result) == "open" + assert calls == ["TCK-1"] + + +async def test_a_handler_reached_without_a_stash_refuses_to_suspend_from_there(): + """A defer the CLI dropped must correct the model, not run the human step.""" + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool()]) + + with active_channel(channel): + result = await _call(config, {"ticket_id": "TCK-1"}) + + assert result.isError + assert "never suspended" in _result_text(result) + + +@pytest.mark.parametrize( + ("value", "trigger_type"), + [ + (ESCALATION, UiPathResumeTriggerType.TASK), + ( + [ + WaitJob(job=Job(key="job-1", id=0)), + WaitJob(job=Job(key="job-2", id=0)), + ], + UiPathResumeTriggerType.JOB, + ), + ("Which vendor?", UiPathResumeTriggerType.API), + ], +) +async def test_the_platform_decides_the_trigger_from_the_value_alone( + value: Any, trigger_type: UiPathResumeTriggerType +): + """Nothing here classifies a suspend value, so every model works untouched. + + A list is sibling triggers for one interrupt, resolved by whichever fires + first, and anything the creator does not recognise degrades to an API + trigger rather than failing. + """ + channel = SuspendChannel() + config = uipath_tool_server(SERVER_KEY, tools=[_review_tool(value=value)]) + + await _gate(channel, config, {"ticket_id": "TCK-1"}) + + assert channel.pending is not None + creator = UiPathResumeTriggerCreator() + targets = ( + channel.pending.value + if isinstance(channel.pending.value, list) + else [channel.pending.value] + ) + for target in targets: + assert creator._determine_trigger_type(target) is trigger_type + + +async def test_two_uipath_servers_do_not_pick_up_each_other_results(): + """Same tool name, same arguments, two servers, two different bodies.""" + first = uipath_tool_server("alpha", tools=[_named_echo("alpha")]) + second = uipath_tool_server("beta", tools=[_named_echo("beta")]) + channel = SuspendChannel() + index = uipath_tool_index({"alpha": first, "beta": second}) + hooks = build_suspend_hooks(channel, index) + args = {"ticket_id": "TCK-1"} + + for qualified in ("mcp__alpha__review", "mcp__beta__review"): + hook_input: PreToolUseHookInput = { + "hook_event_name": "PreToolUse", + "tool_name": qualified, + "tool_input": args, + "tool_use_id": TOOL_USE_ID, + "session_id": "session-1", + "transcript_path": "", + "cwd": "", + } + await hooks["PreToolUse"][0].hooks[0]( + hook_input, TOOL_USE_ID, HookContext(signal=None) + ) + + with active_channel(channel): + assert _result_text(await _call(first, args)) == "alpha" + assert _result_text(await _call(second, args)) == "beta" + + +def _named_echo(label: str): + @tool(TOOL_NAME, "Echo which server answered.", {"ticket_id": str}) + async def echo(args: dict[str, Any]) -> dict[str, Any]: + return _text(label) + + return echo diff --git a/packages/uipath-claude-sdk/tests/test_mcp.py b/packages/uipath-claude-sdk/tests/test_mcp.py new file mode 100644 index 00000000..5f3c15b2 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_mcp.py @@ -0,0 +1,198 @@ +"""Tests for the MCP server helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from uipath_claude_sdk.interrupts import UIPATH_MCP_SERVER_NAME +from uipath_claude_sdk.mcp import ( + remote_mcp_server, + uipath_mcp_server, + validate_mcp_servers, +) + +URL = "https://cloud.uipath.com/org/tenant/agenthub_/mcp/demo/mcp" + + +@pytest.fixture(autouse=True) +def _no_ambient_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + + +class TestRemoteMcpServer: + def test_http_is_the_default_transport(self) -> None: + assert remote_mcp_server(URL) == {"type": "http", "url": URL} + + def test_sse_transport_shape(self) -> None: + assert remote_mcp_server(URL, transport="sse") == { + "type": "sse", + "url": URL, + } + + def test_access_token_becomes_a_bearer_header(self) -> None: + config = remote_mcp_server(URL, access_token="tok") + + assert config["headers"] == {"Authorization": "Bearer tok"} + + def test_token_is_read_from_the_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "from-env") + + config = remote_mcp_server(URL) + + assert config["headers"] == {"Authorization": "Bearer from-env"} + + def test_explicit_token_wins_over_the_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "from-env") + + config = remote_mcp_server(URL, access_token="explicit") + + assert config["headers"] == {"Authorization": "Bearer explicit"} + + def test_no_token_leaves_the_request_unauthenticated(self) -> None: + assert "headers" not in remote_mcp_server(URL) + + def test_empty_token_leaves_the_request_unauthenticated(self) -> None: + assert "headers" not in remote_mcp_server(URL, access_token="") + + def test_extra_headers_are_merged_with_the_bearer_header(self) -> None: + config = remote_mcp_server( + URL, access_token="tok", headers={"X-UIPATH-FolderKey": "folder-1"} + ) + + assert config["headers"] == { + "X-UIPATH-FolderKey": "folder-1", + "Authorization": "Bearer tok", + } + + def test_caller_authorization_header_wins(self) -> None: + config = remote_mcp_server( + URL, access_token="tok", headers={"Authorization": "Basic abc"} + ) + + assert config["headers"] == {"Authorization": "Basic abc"} + + def test_caller_headers_are_copied(self) -> None: + headers = {"X-Trace": "1"} + + remote_mcp_server(URL, access_token="tok", headers=headers) + + assert headers == {"X-Trace": "1"} + + def test_empty_url_is_rejected(self) -> None: + with pytest.raises(ValueError, match="non-empty url"): + remote_mcp_server("") + + def test_unsupported_transport_is_rejected(self) -> None: + with pytest.raises(ValueError, match="Unsupported MCP transport"): + remote_mcp_server(URL, transport="stdio") # type: ignore[arg-type] + + +@dataclass +class FakeMcpServer: + mcp_url: str | None = URL + + +@dataclass +class FakeMcpService: + server: FakeMcpServer + calls: list[tuple[Any, dict[str, Any]]] = field(default_factory=list) + + def retrieve(self, slug: Any = None, **kwargs: Any) -> FakeMcpServer: + self.calls.append((slug, kwargs)) + return self.server + + +@dataclass +class FakeConfig: + secret: str = "sdk-secret" + + +class FakeUiPath: + def __init__(self, server: FakeMcpServer | None = None) -> None: + self.mcp = FakeMcpService(server or FakeMcpServer()) + self._config = FakeConfig() + + +@pytest.fixture +def fake_sdk(monkeypatch: pytest.MonkeyPatch) -> list[FakeUiPath]: + created: list[FakeUiPath] = [] + server = FakeMcpServer() + + def factory() -> FakeUiPath: + sdk = FakeUiPath(server) + created.append(sdk) + return sdk + + monkeypatch.setattr("uipath.platform.UiPath", factory) + return created + + +class TestUiPathMcpServer: + def test_resolves_the_tenant_url_and_credential( + self, fake_sdk: list[FakeUiPath] + ) -> None: + config = uipath_mcp_server("demo-server", folder_path="Shared") + + assert config == { + "type": "http", + "url": URL, + "headers": {"Authorization": "Bearer sdk-secret"}, + } + assert fake_sdk[0].mcp.calls == [ + ("demo-server", {"name": None, "folder_path": "Shared"}) + ] + + def test_looks_the_server_up_by_display_name( + self, fake_sdk: list[FakeUiPath] + ) -> None: + uipath_mcp_server(name="My Server") + + assert fake_sdk[0].mcp.calls == [ + (None, {"name": "My Server", "folder_path": None}) + ] + + def test_sse_transport_is_selectable(self, fake_sdk: list[FakeUiPath]) -> None: + config = uipath_mcp_server("demo-server", transport="sse") + + assert config["type"] == "sse" + + def test_explicit_token_wins_over_the_sdk_credential( + self, fake_sdk: list[FakeUiPath] + ) -> None: + config = uipath_mcp_server("demo-server", access_token="explicit") + + assert config["headers"] == {"Authorization": "Bearer explicit"} + + def test_server_without_a_url_is_rejected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + "uipath.platform.UiPath", lambda: FakeUiPath(FakeMcpServer(mcp_url=None)) + ) + + with pytest.raises(ValueError, match="has no URL configured"): + uipath_mcp_server("demo-server") + + +class TestMcpServers: + def test_returns_a_copy_of_the_mapping(self) -> None: + servers: dict[str, Any] = {"demo": remote_mcp_server(URL)} + + validated = validate_mcp_servers(servers) + + assert validated == servers + assert validated is not servers + + def test_reserved_name_is_rejected(self) -> None: + with pytest.raises(ValueError, match=UIPATH_MCP_SERVER_NAME): + validate_mcp_servers({UIPATH_MCP_SERVER_NAME: remote_mcp_server(URL)}) + + def test_empty_mapping_is_allowed(self) -> None: + assert validate_mcp_servers({}) == {} diff --git a/packages/uipath-claude-sdk/tests/test_resume_payload_shapes.py b/packages/uipath-claude-sdk/tests/test_resume_payload_shapes.py new file mode 100644 index 00000000..49106c03 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_resume_payload_shapes.py @@ -0,0 +1,136 @@ +"""The parked call must resolve from either resume payload shape. + +``UiPathResumableRuntime`` hands the delegate one of two things. When it read a +fired trigger itself it passes the ``{interrupt_id: payload}`` map it built. +When the caller supplied input explicitly, as +``uipath run agent '{"answer": "Acme"}' --resume`` does, it deletes the trigger +and passes that input through unwrapped. + +Accepting only the first shape looks correct in a test that builds the map by +hand, and fails in the field: the answer is dropped, the gate claims a fresh +interrupt id, and the run suspends again under a new trigger while the old one +is already gone. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from tests.conftest import make_session_paths +from uipath_claude_sdk import ClaudeAgent +from uipath_claude_sdk.interrupts import PendingSuspend, SuspendChannel +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore + +TOOL_USE_ID = "toolu_01" +INTERRUPT_ID = TOOL_USE_ID +ANSWER = {"answer": "Acme"} + + +def _pending() -> PendingSuspend: + return PendingSuspend( + interrupt_id=INTERRUPT_ID, + tool_name="mcp__uipath__wait_for_input", + value="Which vendor?", + tool_use_id=TOOL_USE_ID, + ) + + +def _runtime(tmp_path: Path, store: ClaudeSessionStore) -> UiPathClaudeSDKRuntime: + return UiPathClaudeSDKRuntime( + agent=ClaudeAgent(), + session_store=store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + + +async def _restore( + tmp_path: Path, + store: ClaudeSessionStore, + input: dict[str, Any] | None, + resuming: bool = True, +) -> tuple[SuspendChannel, PendingSuspend | None]: + await store.set_pending_suspend(_pending()) + runtime = _runtime(tmp_path, store) + channel = SuspendChannel() + restored = await runtime._restore_pending(channel, input, resuming) + return channel, restored + + +async def test_keyed_resume_map_resolves_the_parked_call( + tmp_path: Path, session_store: ClaudeSessionStore +): + channel, restored = await _restore(tmp_path, session_store, {INTERRUPT_ID: ANSWER}) + + assert restored is not None + assert restored.interrupt_id == INTERRUPT_ID + assert channel.resolved_for(INTERRUPT_ID) == ANSWER + + +async def test_bare_payload_resolves_the_parked_call( + tmp_path: Path, session_store: ClaudeSessionStore +): + """The shape a human types at the CLI, with no interrupt id in sight.""" + channel, restored = await _restore(tmp_path, session_store, ANSWER) + + assert restored is not None + assert restored.interrupt_id == INTERRUPT_ID + assert channel.resolved_for(INTERRUPT_ID) == ANSWER + + +async def test_empty_resume_reparks_under_the_same_interrupt_id( + tmp_path: Path, session_store: ClaudeSessionStore +): + """A resume with nothing to deliver must not mint a second trigger. + + The record is restored but left unresolved, so the gate re-parks the same + call under its original interrupt id rather than claiming a fresh one and + leaving the first trigger orphaned. + """ + channel, restored = await _restore(tmp_path, session_store, None) + + assert restored is None + assert channel.pending is not None + assert channel.pending.interrupt_id == INTERRUPT_ID + assert channel.resolved_for(INTERRUPT_ID) is None + + +async def test_a_fresh_run_never_consumes_a_pending_record( + tmp_path: Path, session_store: ClaudeSessionStore +): + """Without the resume flag the stored record must stay untouched.""" + channel, restored = await _restore(tmp_path, session_store, ANSWER, resuming=False) + + assert restored is None + assert channel.pending is None + + +@pytest.mark.parametrize("payload", [ANSWER, {INTERRUPT_ID: ANSWER}]) +async def test_resume_never_validates_against_the_input_schema( + tmp_path: Path, session_store: ClaudeSessionStore, payload: dict[str, Any] +): + """A resume must not be checked against the entrypoint input schema. + + The resume map is not entrypoint input, so validating it reports a missing + required field when the real state is that an interrupt is being answered. + """ + from uipath_claude_sdk.runtime.runtime import RESUME_PROMPT, RunContext + + await session_store.set_pending_suspend(_pending()) + runtime = _runtime(tmp_path, session_store) + channel = SuspendChannel() + restored = await runtime._restore_pending(channel, payload, True) + context = RunContext( + channel=channel, + sdk_options=runtime.agent.options, + session_id="session-1", + pending=restored, + resuming=True, + ) + + assert runtime._user_message_for_run(payload, context) == RESUME_PROMPT diff --git a/packages/uipath-claude-sdk/tests/test_runtime.py b/packages/uipath-claude-sdk/tests/test_runtime.py new file mode 100644 index 00000000..e32a0075 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_runtime.py @@ -0,0 +1,299 @@ +"""Tests for the standard Claude SDK runtime.""" + +from __future__ import annotations + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + TaskNotificationMessage, + TaskStartedMessage, + TaskUpdatedMessage, + TextBlock, +) +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.events import UiPathRuntimeStateEvent + +from tests.conftest import make_result_message, make_session_paths +from uipath_claude_sdk import UiPathModel +from uipath_claude_sdk.runtime.errors import UiPathClaudeSDKRuntimeError +from uipath_claude_sdk.runtime.gateway import ModelNotInCatalogError +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime + + +class FakeShim: + """Stands in for GatewayShim, recording lifecycle calls.""" + + def __init__(self, factory: "FakeShimFactory") -> None: + self._factory = factory + self.started = 0 + self.stopped = 0 + + async def start(self) -> None: + if self._factory.start_error is not None: + raise self._factory.start_error + self.started += 1 + + async def stop(self) -> None: + self.stopped += 1 + + @property + def base_url(self) -> str: + return "http://127.0.0.1:1234" + + @property + def api_key(self) -> str: + return "run-secret" + + @property + def resolved_model(self) -> str: + return "resolved-sonnet" + + def build_env(self) -> dict[str, str]: + return { + "ANTHROPIC_BASE_URL": self.base_url, + "ANTHROPIC_API_KEY": self.api_key, + "ANTHROPIC_DEFAULT_SONNET_MODEL": self.resolved_model, + } + + +class FakeShimFactory: + def __init__(self) -> None: + self.instances: list[FakeShim] = [] + self.start_error: Exception | None = None + + def __call__(self, llm: object, **kwargs: object) -> FakeShim: + shim = FakeShim(self) + self.instances.append(shim) + return shim + + +@pytest.fixture +def fake_shim(monkeypatch: pytest.MonkeyPatch) -> FakeShimFactory: + factory = FakeShimFactory() + monkeypatch.setattr( + "uipath_claude_sdk.runtime.runtime.GatewayShim", factory, raising=True + ) + return factory + + +def _make_runtime(agent, tmp_path, session_store, runtime_id: str = "rt-1"): + return UiPathClaudeSDKRuntime( + agent=agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path, runtime_id), + runtime_id=runtime_id, + ) + + +async def test_execute_structured( + structured_agent, fake_client, tmp_path, session_store +): + runtime = _make_runtime(structured_agent, tmp_path, session_store) + result = await runtime.execute({"topic": "penguins"}) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"summary": "Hello"} + assert fake_client.last_query == "Summarize penguins" + assert fake_client.last_options is not None + # Structured output is requested natively from the SDK. + assert fake_client.last_options.output_format == { + "type": "json_schema", + "schema": structured_agent.output_schema.model_json_schema(), + } + # Isolated from user/project/local Claude config by default. + assert fake_client.last_options.setting_sources == [] + + +async def test_execute_plain_result(plain_agent, fake_client, tmp_path, session_store): + fake_client.scripted_messages = [make_result_message(result="plain text")] + runtime = _make_runtime(plain_agent, tmp_path, session_store) + result = await runtime.execute({"input": "hi"}) + + assert result.output == {"result": "plain text"} + assert fake_client.last_query == "hi" + + +async def test_stream_yields_state_events_then_result( + structured_agent, fake_client, tmp_path, session_store +): + runtime = _make_runtime(structured_agent, tmp_path, session_store) + events = [event async for event in runtime.stream({"topic": "penguins"})] + + assert isinstance(events[-1], UiPathRuntimeResult) + state_events = [e for e in events if isinstance(e, UiPathRuntimeStateEvent)] + node_names = [e.node_name for e in state_events] + assert "assistant" in node_names + assert "tool_call" in node_names + + +async def test_waits_for_parallel_background_agent_and_workflow_tasks( + plain_agent, fake_client, tmp_path, session_store +): + fake_client.scripted_messages = [ + TaskStartedMessage( + subtype="task_started", + data={}, + task_id="agent-1", + description="research", + uuid="task-start-1", + session_id="session-1", + task_type="local_agent", + ), + TaskStartedMessage( + subtype="task_started", + data={}, + task_id="workflow-1", + description="verification", + uuid="task-start-2", + session_id="session-1", + task_type="local_workflow", + ), + make_result_message(result="Both tasks were launched."), + TaskNotificationMessage( + subtype="task_notification", + data={}, + task_id="agent-1", + status="completed", + output_file="agent-1.output", + summary="Research complete", + uuid="task-end-1", + session_id="session-1", + ), + TaskUpdatedMessage( + subtype="task_updated", + data={}, + task_id="workflow-1", + patch={"status": "completed"}, + status="completed", + session_id="session-1", + uuid="task-end-2", + ), + AssistantMessage( + content=[TextBlock(text="Final answer after both tasks.")], + model="claude-sonnet-4-5", + ), + make_result_message(result="Final answer after both tasks."), + ] + runtime = _make_runtime(plain_agent, tmp_path, session_store) + + events = [event async for event in runtime.stream({"input": "delegate"})] + + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.output == {"result": "Final answer after both tasks."} + task_events = [ + event + for event in events + if isinstance(event, UiPathRuntimeStateEvent) and event.node_name == "task" + ] + assert [event.payload.get("status") for event in task_events] == [ + None, + None, + "completed", + "completed", + ] + + +async def test_does_not_wait_for_unbounded_background_shell( + plain_agent, fake_client, tmp_path, session_store +): + fake_client.scripted_messages = [ + TaskStartedMessage( + subtype="task_started", + data={}, + task_id="shell-1", + description="development server", + uuid="task-start-1", + session_id="session-1", + task_type="local_bash", + ), + make_result_message(result="Server is running."), + make_result_message(result="This result must not be consumed."), + ] + runtime = _make_runtime(plain_agent, tmp_path, session_store) + + result = await runtime.execute({"input": "start server"}) + + assert result.output == {"result": "Server is running."} + + +async def test_input_validation_error( + structured_agent, fake_client, tmp_path, session_store +): + runtime = _make_runtime(structured_agent, tmp_path, session_store) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="input schema"): + await runtime.execute({"wrong_field": 1}) + + +async def test_error_result_raises( + structured_agent, fake_client, tmp_path, session_store +): + fake_client.scripted_messages = [make_result_message(result="boom", is_error=True)] + runtime = _make_runtime(structured_agent, tmp_path, session_store) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="boom"): + await runtime.execute({"topic": "penguins"}) + + +async def test_no_uipath_llm_injects_no_env( + plain_agent, fake_client, tmp_path, session_store +): + runtime = _make_runtime(plain_agent, tmp_path, session_store) + await runtime.execute({"input": "hi"}) + + injected = fake_client.last_options.env + assert "ANTHROPIC_BASE_URL" not in injected + assert "ANTHROPIC_API_KEY" not in injected + assert fake_client.last_options.model == plain_agent.options.model + + +async def test_uipath_llm_starts_gateway_and_overrides_model( + plain_agent, fake_client, fake_shim, tmp_path, session_store +): + plain_agent.uipath_llm = UiPathModel("claude-sonnet-4-5") + runtime = _make_runtime(plain_agent, tmp_path, session_store) + await runtime.execute({"input": "hi"}) + + assert fake_client.last_options.model == "resolved-sonnet" + assert fake_client.last_options.env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:1234" + assert fake_client.last_options.env["ANTHROPIC_API_KEY"] == "run-secret" + # The shim is stopped by the runtime's own finally, not only by dispose(). + assert fake_shim.instances[0].stopped == 1 + assert runtime._shim is None + + +async def test_gateway_start_failure_is_mapped( + plain_agent, fake_shim, tmp_path, session_store +): + fake_shim.start_error = ModelNotInCatalogError("nope", ["a", "b"]) + plain_agent.uipath_llm = UiPathModel("nope") + runtime = _make_runtime(plain_agent, tmp_path, session_store) + + with pytest.raises(UiPathClaudeSDKRuntimeError, match="not available"): + await runtime.execute({"input": "hi"}) + + +async def test_native_output_format_result(fake_client, tmp_path, session_store): + from claude_agent_sdk import ClaudeAgentOptions + + from uipath_claude_sdk import ClaudeAgent + + agent = ClaudeAgent( + options=ClaudeAgentOptions( + model="claude-sonnet-4-5", + output_format={ + "type": "json_schema", + "schema": {"type": "object", "properties": {"summary": {}}}, + }, + ) + ) + fake_client.scripted_messages = [ + make_result_message(result="text", structured_output={"summary": "native"}) + ] + runtime = _make_runtime(agent, tmp_path, session_store) + result = await runtime.execute({"input": "hi"}) + + assert result.output == {"summary": "native"} + # The user's own output_format is preserved, not overwritten. + assert fake_client.last_options.output_format["schema"]["properties"] == { + "summary": {} + } diff --git a/packages/uipath-claude-sdk/tests/test_schema.py b/packages/uipath-claude-sdk/tests/test_schema.py new file mode 100644 index 00000000..4020564c --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_schema.py @@ -0,0 +1,106 @@ +"""Tests for schema and graph extraction.""" + +from __future__ import annotations + +from uipath_claude_sdk.runtime.schema import ( + get_agent_graph, + get_entrypoints_schema, +) + + +def test_structured_schema(structured_agent): + schema = get_entrypoints_schema(structured_agent) + assert schema["input"]["properties"]["topic"]["type"] == "string" + assert schema["input"]["required"] == ["topic"] + assert schema["output"]["properties"]["summary"]["type"] == "string" + + +def test_default_schema(plain_agent): + schema = get_entrypoints_schema(plain_agent) + assert "input" in schema["input"]["properties"] + assert "result" in schema["output"]["properties"] + + +def test_conversational_schema(structured_agent): + schema = get_entrypoints_schema(structured_agent, conversational=True) + assert "messages" in schema["input"]["properties"] + assert "messages" in schema["output"]["properties"] + + +def test_a_conversation_requires_nothing_of_its_caller(structured_agent): + """An empty payload has to validate. + + The host supplies the conversation, so the run form starts out empty, and + an exchange answers over the chat bridge rather than in its output. Marking + either side required makes the platform reject a payload the runtime is + happy with. + """ + schema = get_entrypoints_schema(structured_agent, conversational=True) + assert schema["input"]["required"] == [] + assert schema["output"]["required"] == [] + + +def test_graph_nodes_unique_and_connected(structured_agent): + graph = get_agent_graph(structured_agent) + ids = [node.id for node in graph.nodes] + assert len(ids) == len(set(ids)) + assert "__start__" in ids and "__end__" in ids and "research" in ids + for edge in graph.edges: + assert edge.source in ids + assert edge.target in ids + model_node = next(n for n in graph.nodes if n.id == "research") + assert model_node.type == "model" + assert model_node.metadata == { + "model_name": "anthropic.claude-sonnet-4-5-20250929-v1:0" + } + + +def test_schema_from_native_output_format(): + from claude_agent_sdk import ClaudeAgentOptions + + from uipath_claude_sdk import ClaudeAgent + + native_schema = { + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + } + agent = ClaudeAgent( + options=ClaudeAgentOptions( + model="claude-sonnet-4-5", + output_format={"type": "json_schema", "schema": native_schema}, + ) + ) + schema = get_entrypoints_schema(agent) + assert schema["output"] == native_schema + + +def test_graph_model_name_prefers_uipath_llm(structured_agent): + """The graph label comes from uipath_llm, not options.model. + + get_agent_graph runs at init time, before any run has resolved a model into + options.model, so a gateway-routed agent would otherwise render with no + model on its node in Studio Web. + """ + from dataclasses import replace + + from claude_agent_sdk import ClaudeAgentOptions + + from uipath_claude_sdk import UiPathModel + + agent = replace( + structured_agent, + options=ClaudeAgentOptions(), + uipath_llm=UiPathModel("claude-sonnet-4-5"), + ) + node = next(n for n in get_agent_graph(agent).nodes if n.id == "research") + assert node.type == "model" + assert node.metadata == {"model_name": "claude-sonnet-4-5"} + + +def test_graph_model_name_falls_back_to_options_model(structured_agent): + from dataclasses import replace + + agent = replace(structured_agent, uipath_llm=None) + node = next(n for n in get_agent_graph(agent).nodes if n.id == "research") + assert node.metadata == {"model_name": "anthropic.claude-sonnet-4-5-20250929-v1:0"} diff --git a/packages/uipath-claude-sdk/tests/test_session_store_records.py b/packages/uipath-claude-sdk/tests/test_session_store_records.py new file mode 100644 index 00000000..088d6791 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_session_store_records.py @@ -0,0 +1,157 @@ +"""What the store does with state it cannot use. + +Everything here comes back out of the state database, which survives a +suspension and therefore outlives the code that wrote it. A record written by an +older version, or truncated, must not take the run down: the reader drops what +it cannot rebuild and says so, and the caller treats that as "nothing stored". +""" + +from __future__ import annotations + +import logging + +import pytest +from uipath.platform.common import CreateTask, InvokeProcess + +from uipath_claude_sdk.runtime.session_store import ( + ClaudeSessionStore, + EntrypointRecord, + TranscriptRecord, + _pending_from_stored, + _rebuild_value, +) + + +class _Storage: + """The runtime key-value table, in memory.""" + + def __init__(self) -> None: + self.values: dict[tuple[str, str, str], object] = {} + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> object: + return self.values.get((runtime_id, namespace, key)) + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: object + ) -> None: + self.values[(runtime_id, namespace, key)] = value + + +@pytest.fixture +def store() -> ClaudeSessionStore: + return ClaudeSessionStore(_Storage(), "runtime-1") # type: ignore[arg-type] + + +class TestTranscript: + def test_a_round_trip_keeps_every_field(self): + record = TranscriptRecord( + session_id="s1", project_dir="-tmp-agent", content='{"a": 1}' + ) + assert TranscriptRecord.from_stored(record.to_stored()) == record + + @pytest.mark.parametrize("raw", [None, "not a record", 7, ["s1"]]) + def test_anything_that_is_not_a_record_reads_as_nothing(self, raw): + assert TranscriptRecord.from_stored(raw) is None + + def test_a_record_missing_a_field_reads_as_nothing(self, caplog): + with caplog.at_level(logging.WARNING): + assert TranscriptRecord.from_stored({"session_id": "s1"}) is None + assert "incomplete transcript record" in caplog.text + + +class TestEntrypoint: + def test_a_round_trip_keeps_the_entrypoint(self): + record = EntrypointRecord(entrypoint="agent") + assert EntrypointRecord.from_stored(record.to_stored()) == record + + @pytest.mark.parametrize("raw", [None, {}, {"other": "agent"}, "agent"]) + def test_anything_without_an_entrypoint_reads_as_nothing(self, raw): + assert EntrypointRecord.from_stored(raw) is None + + +class TestRebuildValue: + def test_a_known_model_is_restored_to_its_type(self): + task = CreateTask(title="Approve?", app_name="generic_escalation_app") + rebuilt = _rebuild_value("CreateTask", task.model_dump(), "toolu_1") + assert isinstance(rebuilt, CreateTask) + assert rebuilt.title == "Approve?" + + def test_siblings_are_restored_one_by_one(self): + stored = [ + InvokeProcess(name="p1", input_arguments={}).model_dump(), + InvokeProcess(name="p2", input_arguments={}).model_dump(), + ] + rebuilt = _rebuild_value("InvokeProcess", stored, "toolu_1") + assert [type(item) for item in rebuilt] == [InvokeProcess, InvokeProcess] + + def test_a_plain_value_is_left_alone(self): + assert _rebuild_value("str", "Which vendor?", "toolu_1") == "Which vendor?" + assert _rebuild_value(None, "Which vendor?", "toolu_1") == "Which vendor?" + + def test_an_unknown_type_is_reloaded_as_stored_and_reported(self, caplog): + with caplog.at_level(logging.WARNING): + assert _rebuild_value("SomeFutureModel", {"a": 1}, "toolu_1") == {"a": 1} + assert "not a known" in caplog.text + + +class TestPendingSuspend: + def test_anything_that_is_not_a_record_reads_as_nothing(self, caplog): + assert _pending_from_stored(None) is None + with caplog.at_level(logging.WARNING): + assert _pending_from_stored("not a record") is None + assert "not an object" in caplog.text + + def test_a_record_missing_a_field_reads_as_nothing(self, caplog): + with caplog.at_level(logging.WARNING): + assert _pending_from_stored({"interrupt_id": "toolu_1"}) is None + assert "incomplete pending suspend record" in caplog.text + + def test_a_stored_tool_use_id_stays_a_string(self): + pending = _pending_from_stored( + { + "interrupt_id": "toolu_1", + "tool_name": "mcp__uipath__wait_for_input", + "value": "Which vendor?", + "value_type": "str", + "tool_use_id": "toolu_1", + "requested_at": "2026-01-01T00:00:00+00:00", + } + ) + assert pending is not None + assert pending.tool_use_id == "toolu_1" + + def test_a_record_without_a_tool_use_id_keeps_it_absent(self): + pending = _pending_from_stored( + { + "interrupt_id": "toolu_1", + "tool_name": "mcp__uipath__wait_for_input", + "value": "Which vendor?", + "value_type": "str", + "tool_use_id": None, + "requested_at": "2026-01-01T00:00:00+00:00", + } + ) + assert pending is not None + assert pending.tool_use_id is None + + +class TestStore: + async def test_an_empty_store_reports_nothing_stored(self, store): + assert await store.get_session_id() is None + assert await store.get_pending_suspend() is None + assert await store.get_transcript() is None + + async def test_a_transcript_survives_a_round_trip_and_a_clear(self, store): + record = TranscriptRecord(session_id="s1", project_dir="-tmp", content="{}") + await store.set_transcript(record) + assert await store.get_transcript() == record + + await store.clear_transcript() + assert await store.get_transcript() is None + + async def test_a_session_id_that_is_not_a_string_reads_as_nothing(self, store): + await store.set_session_id("s1") + assert await store.get_session_id() == "s1" + + await store._set("session_id", 7) + assert await store.get_session_id() is None diff --git a/packages/uipath-claude-sdk/tests/test_suspend_resume.py b/packages/uipath-claude-sdk/tests/test_suspend_resume.py new file mode 100644 index 00000000..bc50e575 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_suspend_resume.py @@ -0,0 +1,397 @@ +"""Tests for durable suspension: defer, persist, resume, deliver, clear.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ( + ClaudeAgentOptions, + HookContext, + HookJSONOutput, + HookMatcher, + PreToolUseHookInput, + ResultMessage, + tool, +) +from claude_agent_sdk.types import DeferredToolUse +from mcp.types import CallToolRequest, CallToolRequestParams +from uipath.core.triggers import UiPathResumeTriggerType +from uipath.platform.common import CreateTask, WaitJob +from uipath.platform.orchestrator.job import Job +from uipath.platform.resume_triggers import UiPathResumeTriggerCreator +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus, UiPathStreamOptions + +from tests.conftest import make_result_message, make_session_paths +from uipath_claude_sdk import ClaudeAgent, interrupt, uipath_tool_server +from uipath_claude_sdk.interrupts import PendingSuspend +from uipath_claude_sdk.runtime.errors import UiPathClaudeSDKRuntimeError +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime +from uipath_claude_sdk.runtime.session_store import ClaudeSessionStore + +SERVER_KEY = "tickets" +TOOL_NAME = "ask" +INTERRUPT_TOOL = f"mcp__{SERVER_KEY}__{TOOL_NAME}" +QUESTION = "Should I ship it?" +TOOL_USE_ID = "toolu_1" +ARGS = {"question": QUESTION} + + +@tool(TOOL_NAME, "Ask a human and wait for the answer.", {"question": str}) +async def ask(args: dict[str, Any]) -> dict[str, Any]: + answer = await interrupt(args["question"]) + return {"content": [{"type": "text", "text": str(answer)}]} + + +def hitl_options(**overrides: Any) -> ClaudeAgentOptions: + servers: dict[str, Any] = {SERVER_KEY: uipath_tool_server(SERVER_KEY, tools=[ask])} + servers.update(overrides.pop("extra_servers", {})) + return ClaudeAgentOptions(mcp_servers=servers, **overrides) + + +def _permission_decision(output: HookJSONOutput) -> str: + """The decision the CLI reads back from the hook, as JSON would carry it.""" + payload: dict[str, Any] = dict(output) + if not payload: + return "none" + decision: str = payload["hookSpecificOutput"]["permissionDecision"] + return decision + + +def _output(result: UiPathRuntimeResult) -> dict[str, Any]: + """The result's output as the interrupt map the runtime yields.""" + assert isinstance(result.output, dict) + return result.output + + +class InterruptClient: + """Fake CLI that runs the real PreToolUse hook and honours its decision. + + On ``defer`` it reports a parked call the way the CLI does. On ``allow`` it + invokes the real in-process MCP handler, so the resumed payload travels the + same path it does in a live run. + """ + + last_options: ClaudeAgentOptions | None = None + last_query: str | None = None + tool_results: list[str] = [] + session_id: str = "session-1" + + def __init__(self, options: ClaudeAgentOptions) -> None: + type(self).last_options = options + self._options = options + + async def __aenter__(self) -> InterruptClient: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def query(self, message: str) -> None: + type(self).last_query = message + + async def receive_response(self): + """Stop at the result, as the SDK's own convenience method does.""" + async for message in self.receive_messages(): + yield message + if isinstance(message, ResultMessage): + return + + async def receive_messages(self): + decision = await self._run_hook() + if decision == "defer": + yield make_result_message( + result=None, + session_id=type(self).session_id, + deferred_tool_use=DeferredToolUse( + id=TOOL_USE_ID, name=INTERRUPT_TOOL, input=ARGS + ), + ) + return + if decision == "allow": + type(self).tool_results.append(await self._call_tool()) + yield make_result_message(result="done", session_id=type(self).session_id) + + async def _run_hook(self) -> str: + matchers = (self._options.hooks or {}).get("PreToolUse") + if not matchers: + return "none" + hook_input: PreToolUseHookInput = { + "hook_event_name": "PreToolUse", + "tool_name": INTERRUPT_TOOL, + "tool_input": ARGS, + "tool_use_id": TOOL_USE_ID, + "session_id": type(self).session_id, + "transcript_path": "", + "cwd": "", + } + output = await matchers[0].hooks[0]( + hook_input, + TOOL_USE_ID, + HookContext(signal=None), + ) + return _permission_decision(output) + + async def _call_tool(self) -> str: + servers: Any = self._options.mcp_servers + server = servers[SERVER_KEY]["instance"] + for request_type, handler in server.request_handlers.items(): + if request_type.__name__ != "CallToolRequest": + continue + result = await handler( + CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name=TOOL_NAME, arguments=ARGS), + ) + ) + return result.root.content[0].text + raise AssertionError("The tool server exposes no CallToolRequest handler.") + + +@pytest.fixture +def interrupt_client(monkeypatch: pytest.MonkeyPatch) -> type[InterruptClient]: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setattr( + "uipath_claude_sdk.runtime.runtime.ClaudeSDKClient", InterruptClient + ) + InterruptClient.last_options = None + InterruptClient.last_query = None + InterruptClient.tool_results = [] + InterruptClient.session_id = "session-1" + return InterruptClient + + +@pytest.fixture +def hitl_agent() -> ClaudeAgent: + return ClaudeAgent(options=hitl_options()) + + +def _make_runtime( + agent: ClaudeAgent, tmp_path: Path, session_store: ClaudeSessionStore +) -> UiPathClaudeSDKRuntime: + return UiPathClaudeSDKRuntime( + agent=agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="agent", + ) + + +async def _run(runtime, input=None, resume=False) -> UiPathRuntimeResult: + result: UiPathRuntimeResult | None = None + async for event in runtime.stream(input, UiPathStreamOptions(resume=resume)): + if isinstance(event, UiPathRuntimeResult): + result = event + assert result is not None + return result + + +async def test_an_agent_without_uipath_tools_gets_the_options_it_wrote( + plain_agent, interrupt_client, tmp_path, session_store +): + """An agent is what its code says, so a plain one must be untouched. + + No tool, no hook and no suspend-safety environment: this runtime hands the + SDK client exactly the options the developer wrote. + + The tracing instrumentor merges hooks of its own into + ``ClaudeSDKClient.options`` on the way into ``query()``, after this runtime + is done. Those are the instrumentor's and appear wherever it is installed, + which ``test_an_agent_without_uipath_tools_contributes_no_hook_of_its_own`` + pins separately. + """ + base = ClaudeAgentOptions( + mcp_servers={"other": {"type": "stdio", "command": "noop"}}, + allowed_tools=["Bash"], + ) + plain_agent.options = base + runtime = _make_runtime(plain_agent, tmp_path, session_store) + await _run(runtime, {"input": "hi"}) + + options = interrupt_client.last_options + assert options.mcp_servers == base.mcp_servers + assert options.hooks == base.hooks + assert options.allowed_tools == base.allowed_tools + assert "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK" not in options.env + + +async def test_a_uipath_tool_adds_the_hook_and_nothing_else( + hitl_agent, interrupt_client, tmp_path, session_store +): + async def developer_hook(hook_input, tool_use_id, context): + return {} + + base = hitl_options( + extra_servers={"other": {"type": "stdio", "command": "noop"}}, + hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[developer_hook])]}, + allowed_tools=["Bash"], + ) + hitl_agent.options = base + runtime = _make_runtime(hitl_agent, tmp_path, session_store) + await _run(runtime, {"input": "ask me"}) + + options = interrupt_client.last_options + assert options.mcp_servers == base.mcp_servers + assert options.allowed_tools == ["Bash"] + assert len(options.hooks["PreToolUse"]) == 2 + assert options.hooks["PreToolUse"][1].hooks == [developer_hook] + assert options.env["CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK"] == "1" + + paths = make_session_paths(tmp_path) + assert options.cwd == paths.workspace + assert options.env["CLAUDE_CONFIG_DIR"] == str(paths.config_dir) + assert paths.workspace.is_dir() + assert paths.config_dir.is_dir() + + +async def test_suspend_persists_the_parked_call( + hitl_agent, interrupt_client, tmp_path, session_store +): + runtime = _make_runtime(hitl_agent, tmp_path, session_store) + result = await _run(runtime, {"input": "ask me"}) + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert _output(result) == {TOOL_USE_ID: QUESTION} + assert interrupt_client.tool_results == [] + + pending = await session_store.get_pending_suspend() + assert pending is not None + assert pending.interrupt_id == TOOL_USE_ID + assert pending.tool_name == INTERRUPT_TOOL + assert pending.tool_use_id == TOOL_USE_ID + assert await session_store.get_session_id() == "session-1" + + record = await session_store.get_entrypoint() + assert record is not None + assert record.entrypoint == "agent" + + +async def test_resume_delivers_the_payload_and_clears_the_record( + hitl_agent, interrupt_client, tmp_path, session_store +): + await _run(_make_runtime(hitl_agent, tmp_path, session_store), {"input": "ask me"}) + + # A fresh runtime stands in for the resumed process: the hook and the tool + # server are rebuilt, only the store and the paths carry over. + resumed = _make_runtime(hitl_agent, tmp_path, session_store) + result = await _run(resumed, {TOOL_USE_ID: {"answer": "yes"}}, resume=True) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"result": "done"} + assert interrupt_client.tool_results == ["{'answer': 'yes'}"] + assert interrupt_client.last_query == "Continue." + assert interrupt_client.last_options.resume == "session-1" + assert await session_store.get_pending_suspend() is None + + +async def test_resume_without_a_payload_leaves_the_call_parked( + hitl_agent, interrupt_client, tmp_path, session_store +): + await _run(_make_runtime(hitl_agent, tmp_path, session_store), {"input": "ask me"}) + pending = await session_store.get_pending_suspend() + + resumed = _make_runtime(hitl_agent, tmp_path, session_store) + result = await _run(resumed, None, resume=True) + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert interrupt_client.tool_results == [] + still_pending = await session_store.get_pending_suspend() + assert still_pending is not None + assert still_pending.interrupt_id == pending.interrupt_id + + +async def test_resume_without_a_session_id_fails( + hitl_agent, interrupt_client, tmp_path, session_store +): + runtime = _make_runtime(hitl_agent, tmp_path, session_store) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="no Claude session id"): + await _run(runtime, {"input": "hi"}, resume=True) + + +async def test_a_lost_transcript_fails_the_resume( + hitl_agent, interrupt_client, tmp_path, session_store +): + await _run(_make_runtime(hitl_agent, tmp_path, session_store), {"input": "ask me"}) + interrupt_client.session_id = "session-restarted" + + resumed = _make_runtime(hitl_agent, tmp_path, session_store) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="but the run answered from"): + await _run(resumed, {TOOL_USE_ID: {"answer": "yes"}}, resume=True) + + +@pytest.mark.parametrize( + ("value", "trigger_type"), + [ + ( + CreateTask(title="Approve the refund", app_name="generic_escalation_app"), + UiPathResumeTriggerType.TASK, + ), + ( + [WaitJob(job=Job(key="job-1", id=0)), WaitJob(job=Job(key="job-2", id=0))], + UiPathResumeTriggerType.JOB, + ), + ], +) +async def test_a_reloaded_suspend_value_still_picks_the_same_trigger( + session_store, value, trigger_type +): + """Persisting a suspend value must not cost it its type. + + ``UiPathResumeTriggerCreator`` dispatches on the value's type and defaults + to an API trigger for anything it does not recognise. A value that comes + back from the store as a plain dict therefore re-parks on an API trigger + the platform never fires, so the run waits on a resume nobody sends. + """ + await session_store.set_pending_suspend( + PendingSuspend( + interrupt_id=TOOL_USE_ID, + tool_name=INTERRUPT_TOOL, + value=value, + tool_use_id=TOOL_USE_ID, + ) + ) + + reloaded = await session_store.get_pending_suspend() + + assert reloaded is not None + assert reloaded.value == value + creator = UiPathResumeTriggerCreator() + targets = reloaded.value if isinstance(reloaded.value, list) else [reloaded.value] + assert targets + for target in targets: + assert creator._determine_trigger_type(target) is trigger_type + + +async def test_a_reloaded_question_stays_the_plain_string_it_was(session_store): + """A bare string suspends on an API trigger, and text survives the store.""" + await session_store.set_pending_suspend( + PendingSuspend( + interrupt_id=TOOL_USE_ID, + tool_name=INTERRUPT_TOOL, + value=QUESTION, + tool_use_id=TOOL_USE_ID, + ) + ) + + reloaded = await session_store.get_pending_suspend() + + assert reloaded is not None + assert reloaded.value == QUESTION + + +async def test_resume_on_another_entrypoint_is_refused( + hitl_agent, interrupt_client, tmp_path, session_store +): + await _run(_make_runtime(hitl_agent, tmp_path, session_store), {"input": "ask me"}) + + resumed = UiPathClaudeSDKRuntime( + agent=hitl_agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path), + runtime_id="rt-1", + entrypoint="other-agent", + ) + with pytest.raises(UiPathClaudeSDKRuntimeError, match="cannot be\\s+resumed on"): + await _run(resumed, {TOOL_USE_ID: {"answer": "yes"}}, resume=True) diff --git a/packages/uipath-claude-sdk/tests/test_telemetry.py b/packages/uipath-claude-sdk/tests/test_telemetry.py new file mode 100644 index 00000000..d7240c56 --- /dev/null +++ b/packages/uipath-claude-sdk/tests/test_telemetry.py @@ -0,0 +1,739 @@ +"""Tests for the telemetry this package owns around the Claude SDK instrumentor. + +The agent and tool spans come from +``openinference-instrumentation-claude-agent-sdk``. What is tested here is +everything that meets it: the interrupt hook it merges alongside, the LLM spans +the gateway shim is the only source of, and the attributes it writes whose +meaning does not survive the trip to UiPath. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + CLIConnectionError, + HookContext, + HookJSONOutput, + HookMatcher, + PreToolUseHookInput, + tool, +) +from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor +from openinference.semconv.trace import SpanAttributes +from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import StatusCode +from uipath.runtime import UiPathRuntimeContext + +from tests.conftest import make_session_paths +from uipath_claude_sdk import ( + ClaudeAgent, + UiPathModel, + interrupt, + uipath_tool_server, +) +from uipath_claude_sdk.runtime._telemetry import ( + LLM_SPAN_NAME, + AttributeStripper, + GatewayCallTelemetry, +) +from uipath_claude_sdk.runtime.conversational_runtime import ( + UiPathClaudeSDKConversationalRuntime, +) +from uipath_claude_sdk.runtime.factory import UiPathClaudeSDKRuntimeFactory +from uipath_claude_sdk.runtime.gateway.records import GatewayCallRecord +from uipath_claude_sdk.runtime.runtime import UiPathClaudeSDKRuntime + +SERVING_MODEL = "claude-haiku-4-5" +"""Deliberately not the agent's configured model: a gateway call is attributed +to the model that actually served it.""" + +SERVER_KEY = "tickets" +TOOL_NAME = "ask" +INTERRUPT_TOOL = f"mcp__{SERVER_KEY}__{TOOL_NAME}" +TOOL_USE_ID = "toolu_1" +QUESTION = "Should I ship it?" + +LLM_COST_TOTAL = SpanAttributes.LLM_COST_TOTAL +SPAN_KIND = SpanAttributes.OPENINFERENCE_SPAN_KIND +TOOL_ID = SpanAttributes.TOOL_ID +TOOL_NAME_ATTR = SpanAttributes.TOOL_NAME + + +@pytest.fixture +def exporter() -> InMemorySpanExporter: + return InMemorySpanExporter() + + +@pytest.fixture +def provider(exporter: InMemorySpanExporter) -> TracerProvider: + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + return tracer_provider + + +@pytest.fixture +def telemetry(provider: TracerProvider) -> GatewayCallTelemetry: + return GatewayCallTelemetry(provider.get_tracer("test")) + + +def attrs(span: ReadableSpan) -> dict[str, Any]: + return dict(span.attributes or {}) + + +def span_names(exporter: InMemorySpanExporter) -> list[str]: + return [span.name for span in exporter.get_finished_spans()] + + +def make_gateway_record( + *, + resolved_model: str = SERVING_MODEL, + input_tokens: int | None = 11, + output_tokens: int | None = 7, + cache_read_input_tokens: int | None = None, + cache_creation_input_tokens: int | None = None, + error: str | None = None, + traced_upstream: bool = False, +) -> GatewayCallRecord: + return GatewayCallRecord( + requested_model="claude-sonnet-4-5", + resolved_model=resolved_model, + vendor_type="anthropic", + api_flavor="messages", + streaming=True, + status=200 if error is None else 502, + duration_ms=12.5, + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_input_tokens=cache_read_input_tokens, + cache_creation_input_tokens=cache_creation_input_tokens, + error=error, + traced_upstream=traced_upstream, + ) + + +def _make_runtime(agent, tmp_path, session_store, telemetry=None): + runtime = UiPathClaudeSDKRuntime( + agent=agent, + session_store=session_store, + session_paths=make_session_paths(tmp_path, "rt-1"), + runtime_id="rt-1", + ) + runtime.telemetry = telemetry + return runtime + + +# --- The interrupt hook and the instrumentor's hooks ------------------------- + + +@tool(TOOL_NAME, "Ask a human and wait for the answer.", {"question": str}) +async def ask(args: dict[str, Any]) -> dict[str, Any]: + answer = await interrupt(args["question"]) + return {"content": [{"type": "text", "text": str(answer)}]} + + +@pytest.fixture +def hitl_agent() -> ClaudeAgent: + return ClaudeAgent( + options=ClaudeAgentOptions( + mcp_servers={SERVER_KEY: uipath_tool_server(SERVER_KEY, tools=[ask])} + ) + ) + + +async def merged_hooks( + options: ClaudeAgentOptions, +) -> dict[str, list[HookMatcher]]: + """The hooks the CLI is really handed, once the instrumentor has merged its own. + + The instrumentor injects them into ``ClaudeSDKClient.options`` on the way + into ``query()``, before the call reaches the transport, so a client that + was never connected still shows exactly what a connected one would send. + """ + ClaudeAgentSDKInstrumentor().instrument() + client = ClaudeSDKClient(options=options) + with pytest.raises(CLIConnectionError): + await client.query("anything") + hooks = client.options.hooks or {} + return {str(event): matchers for event, matchers in hooks.items()} + + +async def run_pre_tool_use( + matchers: list[HookMatcher], tool_name: str +) -> list[HookJSONOutput]: + """Run every ``PreToolUse`` hook in order, the way the CLI does.""" + hook_input: PreToolUseHookInput = { + "hook_event_name": "PreToolUse", + "tool_name": tool_name, + "tool_input": {"question": QUESTION}, + "tool_use_id": TOOL_USE_ID, + "session_id": "session-1", + "transcript_path": "", + "cwd": "", + } + return [ + await hook(hook_input, TOOL_USE_ID, HookContext(signal=None)) + for matcher in matchers + for hook in matcher.hooks + ] + + +def permission_decision(output: HookJSONOutput) -> str: + payload: dict[str, Any] = dict(output) + if not payload: + return "none" + decision: str = payload["hookSpecificOutput"]["permissionDecision"] + return decision + + +async def test_the_interrupt_hook_still_decides_once_the_instrumentor_merges_its_own( + hitl_agent, fake_client, tmp_path, session_store +): + """The whole suspend feature rides on this hook reaching the CLI. + + The instrumentor injects tracing hooks into the same ``PreToolUse`` event. + If it ever replaced the list instead of merging into it, the interrupt hook + would vanish, every ``interrupt()`` would stop parking its call, and the run + would report success having skipped the human step. + """ + runtime = _make_runtime(hitl_agent, tmp_path, session_store) + await runtime.execute({"input": "ask me"}) + ours = fake_client.last_options + + merged = await merged_hooks(ours) + + assert merged["PreToolUse"][0] is ours.hooks["PreToolUse"][0], ( + "The interrupt hook must stay, and stay first, so its decision is the " + "one the CLI reads." + ) + decisions = [ + permission_decision(output) + for output in await run_pre_tool_use(merged["PreToolUse"], INTERRUPT_TOOL) + ] + assert decisions == ["defer", "none"] + + +async def test_an_agent_without_uipath_tools_contributes_no_hook_of_its_own( + plain_agent, fake_client, tmp_path, session_store +): + """Every hook the CLI ends up with belongs to the instrumentor, none to us. + + ``test_an_agent_without_uipath_tools_gets_the_options_it_wrote`` pins that + this runtime injects nothing. This pins the other half: what the tracing + instrumentor adds on top is the instrumentor's, so an agent still behaves + identically wherever it runs with the same instrumentation installed. + """ + plain_agent.options = ClaudeAgentOptions() + runtime = _make_runtime(plain_agent, tmp_path, session_store) + await runtime.execute({"input": "hi"}) + ours = fake_client.last_options + assert ours.hooks is None + + merged = await merged_hooks(ours) + + assert set(merged) == {"PreToolUse", "PostToolUse", "PostToolUseFailure"} + owners = { + hook.__module__ + for matchers in merged.values() + for matcher in matchers + for hook in matcher.hooks + } + assert all(owner.startswith("openinference.") for owner in owners), owners + assert [ + permission_decision(output) + for output in await run_pre_tool_use(merged["PreToolUse"], "Bash") + ] == ["none"] + + +# --- LLM spans come from the gateway and nowhere else ------------------------ + + +def test_every_gateway_call_becomes_one_llm_span(telemetry, exporter): + telemetry.on_gateway_call(make_gateway_record(input_tokens=11, output_tokens=7)) + telemetry.on_gateway_call( + make_gateway_record( + resolved_model="claude-opus-4-1", input_tokens=3, output_tokens=5 + ) + ) + telemetry.flush_gateway_calls() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [LLM_SPAN_NAME, LLM_SPAN_NAME] + + first, second = (attrs(span) for span in spans) + assert first[SPAN_KIND] == "LLM" + assert first["llm.model_name"] == SERVING_MODEL + assert first["llm.token_count.prompt"] == 11 + assert first["llm.token_count.completion"] == 7 + assert first["llm.token_count.total"] == 18 + + assert second[SPAN_KIND] == "LLM" + assert second["llm.model_name"] == "claude-opus-4-1" + assert second["llm.token_count.prompt"] == 3 + assert second["llm.token_count.completion"] == 5 + assert second["llm.token_count.total"] == 8 + + for span in spans: + assert span.start_time is not None and span.end_time is not None + assert span.end_time > span.start_time + assert span.status.status_code is StatusCode.OK + + +def test_a_call_the_gateway_traced_gets_no_span_from_us(telemetry, exporter): + """The gateway records the model span itself when it was handed the trace. + + Both sides emitting produces the same call twice, which is why every other + UiPath framework drops its client-side span once the headers go out. + """ + telemetry.on_gateway_call(make_gateway_record(traced_upstream=True)) + telemetry.flush_gateway_calls() + + assert span_names(exporter) == [] + + +def test_a_call_the_gateway_did_not_trace_still_gets_our_span(telemetry, exporter): + """The fallback, for a platform with the trace-context feature turned off.""" + telemetry.on_gateway_call(make_gateway_record(traced_upstream=False)) + telemetry.flush_gateway_calls() + + assert span_names(exporter) == [LLM_SPAN_NAME] + + +def test_trace_context_headers_need_a_turn_to_hang_from(telemetry): + """No adopted parent means no span to name, so nothing is claimed.""" + assert telemetry.trace_context_headers() == {} + + +def test_cached_prompt_tokens_join_the_prompt_count(telemetry, exporter): + telemetry.on_gateway_call( + make_gateway_record( + input_tokens=10, + cache_read_input_tokens=100, + cache_creation_input_tokens=5, + output_tokens=2, + ) + ) + telemetry.flush_gateway_calls() + + span = attrs(exporter.get_finished_spans()[0]) + assert span["llm.token_count.prompt"] == 115 + assert span["llm.token_count.completion"] == 2 + + +def test_failed_gateway_call_span_carries_the_error(telemetry, exporter): + telemetry.on_gateway_call(make_gateway_record(error="upstream refused")) + telemetry.flush_gateway_calls() + + span = exporter.get_finished_spans()[0] + assert span.status.status_code is StatusCode.ERROR + assert span.status.description == "upstream refused" + + +async def test_a_run_emits_a_span_for_every_call_the_gateway_served( + structured_agent, fake_client, tmp_path, session_store, telemetry, exporter +): + """No assistant turn claims a record any more, so nothing is ever dropped. + + Background and subagent traffic never reaches the SDK message stream, and + the CLI's own per-message counts describe a fragment of a call rather than + the call. The shim saw all of it, so all of it is reported. + """ + telemetry.on_gateway_call(make_gateway_record()) + telemetry.on_gateway_call(make_gateway_record(resolved_model="claude-opus-4-1")) + runtime = _make_runtime(structured_agent, tmp_path, session_store, telemetry) + + await runtime.execute({"topic": "penguins"}) + + assert span_names(exporter) == [LLM_SPAN_NAME, LLM_SPAN_NAME] + + +async def test_a_run_without_telemetry_emits_nothing( + structured_agent, fake_client, tmp_path, session_store, exporter +): + runtime = _make_runtime(structured_agent, tmp_path, session_store) + + await runtime.execute({"topic": "penguins"}) + + assert exporter.get_finished_spans() == () + + +async def test_gateway_shim_is_given_the_telemetry_sink( + plain_agent, fake_client, tmp_path, session_store, telemetry, monkeypatch +): + captured: dict[str, object] = {} + + class RecordingShim: + def __init__(self, llm, **kwargs): + captured.update(kwargs) + + async def start(self) -> None: + return None + + async def stop(self) -> None: + return None + + @property + def base_url(self) -> str: + return "http://127.0.0.1:1234" + + @property + def resolved_model(self) -> str: + return "resolved-sonnet" + + def build_env(self) -> dict[str, str]: + return {} + + monkeypatch.setattr( + "uipath_claude_sdk.runtime.runtime.GatewayShim", RecordingShim, raising=True + ) + plain_agent.uipath_llm = UiPathModel("claude-sonnet-4-5") + runtime = _make_runtime(plain_agent, tmp_path, session_store, telemetry) + await runtime.execute({"input": "hi"}) + + assert captured["on_call"] == telemetry.on_gateway_call + + +# --- Attributes that must not reach UiPath ----------------------------------- + + +def emit_agent_span(provider: TracerProvider, cost: float | None = 0.0731) -> None: + """One span shaped like the instrumentor's, cost attribute included.""" + attributes: dict[str, Any] = { + SPAN_KIND: "AGENT", + "llm.model_name": SERVING_MODEL, + "llm.token_count.prompt": 945, + } + if cost is not None: + attributes[LLM_COST_TOTAL] = cost + provider.get_tracer("test").start_span( + "ClaudeAgentSDK.ClaudeSDKClient.receive_response", attributes=attributes + ).end() + + +def emit_tool_span(provider: TracerProvider, tool_use_id: str = TOOL_USE_ID) -> None: + """One span shaped like the instrumentor's TOOL span. + + ``tool.id`` carries the ``tool_use_id`` the model minted for this one + invocation, which is what the instrumentor writes there. + """ + provider.get_tracer("test").start_span( + f"Tool.{INTERRUPT_TOOL}", + attributes={ + SPAN_KIND: "TOOL", + TOOL_ID: tool_use_id, + TOOL_NAME_ATTR: INTERRUPT_TOOL, + "input.value": '{"question": "Should I ship it?"}', + }, + ).end() + + +def test_no_exported_span_carries_a_cost(exporter): + """total_cost_usd is the CLI's own estimate, priced off Anthropic's public + catalogue, and says nothing about what a gateway-routed model costs.""" + provider = TracerProvider() + provider.add_span_processor(AttributeStripper()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + emit_agent_span(provider) + + exported = attrs(exporter.get_finished_spans()[0]) + assert [key for key in exported if "cost" in key.lower()] == [] + # Only the cost goes: the rest of the span is untouched. + assert exported[SPAN_KIND] == "AGENT" + assert exported["llm.model_name"] == SERVING_MODEL + assert exported["llm.token_count.prompt"] == 945 + + +def test_no_exported_tool_span_carries_a_tool_id(exporter): + """A per-invocation tool_use_id is not the stable tool identifier UiPath + reads out of tool.id, so it must not be exported under that name.""" + provider = TracerProvider() + provider.add_span_processor(AttributeStripper()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + emit_tool_span(provider) + + exported = attrs(exporter.get_finished_spans()[0]) + assert TOOL_ID not in exported + # Everything the evaluators do read is left in place. + assert exported[TOOL_NAME_ATTR] == INTERRUPT_TOOL + assert exported["input.value"] == '{"question": "Should I ship it?"}' + + +async def test_tool_call_evaluators_match_our_spans_by_name(exporter): + """The contract the stripper exists for. + + ``uipath eval`` buckets a tool call under its ``tool.id`` when the span has + one and only falls back to ``tool.name``. A criterion is authored against + the name, so leaving the tool_use_id in place scores every tool-call + evaluator 0. + """ + from uipath.eval.evaluators import ToolCallCountEvaluator, ToolCallOrderEvaluator + from uipath.eval.evaluators.tool_call_count_evaluator import ( + ToolCallCountEvaluationCriteria, + ToolCallCountEvaluatorConfig, + ) + from uipath.eval.evaluators.tool_call_order_evaluator import ( + ToolCallOrderEvaluationCriteria, + ToolCallOrderEvaluatorConfig, + ) + from uipath.eval.models import WorkloadExecution + + provider = TracerProvider() + provider.add_span_processor(AttributeStripper()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + emit_tool_span(provider) + + execution = WorkloadExecution( + agent_input={}, + workload_output={}, + workload_trace=list(exporter.get_finished_spans()), + ) + + # The three generic type fields are filled in by a model validator, so they + # are not arguments even though mypy's pydantic plugin reads them as such. + count = await ToolCallCountEvaluator( # type: ignore[call-arg] + id="count", evaluatorConfig=ToolCallCountEvaluatorConfig(name="count") + ).evaluate( + execution, + ToolCallCountEvaluationCriteria(tool_calls_count={INTERRUPT_TOOL: ("=", 1)}), + ) + order = await ToolCallOrderEvaluator( # type: ignore[call-arg] + id="order", evaluatorConfig=ToolCallOrderEvaluatorConfig(name="order") + ).evaluate( + execution, + ToolCallOrderEvaluationCriteria(tool_calls_order=[INTERRUPT_TOOL]), + ) + + assert count.score == 1.0 + assert order.score == 1.0 + + +def test_a_span_without_a_cost_is_left_alone(exporter): + provider = TracerProvider() + provider.add_span_processor(AttributeStripper()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + emit_agent_span(provider, cost=None) + + assert attrs(exporter.get_finished_spans()[0])["llm.token_count.prompt"] == 945 + + +def test_the_factory_strips_the_attributes_before_the_exporters_run( + tmp_path, monkeypatch +): + """The stripper is registered through the trace manager, so it lands among + the exporters and runs before the ones the CLI adds once a job is known.""" + monkeypatch.chdir(tmp_path) + exporter = InMemorySpanExporter() + provider = TracerProvider() + trace_manager = FakeTraceManager(provider) + + context = UiPathRuntimeContext(runtime_dir=str(tmp_path / "__uipath")) + context.trace_manager = trace_manager # type: ignore[assignment] + UiPathClaudeSDKRuntimeFactory(context=context) + # Registered after the factory, exactly as the CLI registers LLM Ops. + trace_manager.add_span_processor(SimpleSpanProcessor(exporter)) + + emit_agent_span(provider) + emit_tool_span(provider) + + exported = [attrs(span) for span in exporter.get_finished_spans()] + assert LLM_COST_TOTAL not in exported[0] + assert TOOL_ID not in exported[1] + + +# --- Factory wiring ---------------------------------------------------------- + + +class _DelegatingProcessor(SpanProcessor): + """One processor on the provider, fronting a list that can grow later.""" + + def __init__(self) -> None: + self.processors: list[SpanProcessor] = [] + + def on_start(self, span: Any, parent_context: Any = None) -> None: + for processor in self.processors: + processor.on_start(span, parent_context) + + def on_end(self, span: ReadableSpan) -> None: + for processor in self.processors: + processor.on_end(span) + + +class FakeTraceManager: + """The parts of ``UiPathTraceManager`` the factory touches. + + Processors sit behind a single delegate on the provider, as the real one + arranges them, which is what makes registration order decide who sees a + span first. + """ + + def __init__(self, provider: TracerProvider) -> None: + self.tracer_provider = provider + self._delegate = _DelegatingProcessor() + provider.add_span_processor(self._delegate) + + def add_span_processor(self, span_processor: SpanProcessor) -> "FakeTraceManager": + self._delegate.processors.append(span_processor) + return self + + +def _make_delegate(factory: UiPathClaudeSDKRuntimeFactory, runtime_id: str): + return factory._create_delegate( + agent=None, # type: ignore[arg-type] + runtime_id=runtime_id, + entrypoint="agent", + storage=None, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize("conversational", [False, True]) +def test_factory_assigns_telemetry_per_runtime(tmp_path, monkeypatch, conversational): + monkeypatch.chdir(tmp_path) + context = UiPathRuntimeContext( + runtime_dir=str(tmp_path / "__uipath"), + conversation_id="conv-1" if conversational else None, + ) + factory = UiPathClaudeSDKRuntimeFactory(context=context) + runtime = _make_delegate(factory, "rt-1") + other = _make_delegate(factory, "rt-2") + + expected = ( + UiPathClaudeSDKConversationalRuntime + if conversational + else UiPathClaudeSDKRuntime + ) + assert isinstance(runtime, expected) + assert isinstance(runtime.telemetry, GatewayCallTelemetry) + # Concurrent runs must not share a gateway call buffer. + assert runtime.telemetry is not other.telemetry + + +def test_factory_instruments_the_claude_agent_sdk(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + context = UiPathRuntimeContext(runtime_dir=str(tmp_path / "__uipath")) + + UiPathClaudeSDKRuntimeFactory(context=context) + + assert ClaudeAgentSDKInstrumentor().is_instrumented_by_opentelemetry + + +def test_factory_uses_the_context_trace_manager(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + exporter = InMemorySpanExporter() + provider = TracerProvider() + + context = UiPathRuntimeContext(runtime_dir=str(tmp_path / "__uipath")) + trace_manager = FakeTraceManager(provider) + trace_manager.add_span_processor(SimpleSpanProcessor(exporter)) + context.trace_manager = trace_manager # type: ignore[assignment] + factory = UiPathClaudeSDKRuntimeFactory(context=context) + telemetry = _make_delegate(factory, "rt-1").telemetry + assert isinstance(telemetry, GatewayCallTelemetry) + + telemetry.on_gateway_call(make_gateway_record()) + telemetry.flush_gateway_calls() + + assert span_names(exporter) == [LLM_SPAN_NAME] + + +# --- What the LLM span actually carries ------------------------------------- + +_SSE_TURN = ( + b'event: message_start\ndata: {"type":"message_start","message":{"id":"m",' + b'"type":"message","role":"assistant","model":"m","content":[],' + b'"stop_reason":null,"usage":{"input_tokens":11,"output_tokens":2}}}\n\n' + b'event: content_block_start\ndata: {"type":"content_block_start","index":0,' + b'"content_block":{"type":"text","text":""}}\n\n' + b'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,' + b'"delta":{"type":"text_delta","text":"Looking that up"}}\n\n' + b'event: content_block_start\ndata: {"type":"content_block_start","index":1,' + b'"content_block":{"type":"tool_use","id":"toolu_9","name":"get_rate",' + b'"input":{}}}\n\n' + b'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,' + b'"delta":{"type":"input_json_delta","partial_json":"{\\"city\\":\\"Paris\\"}"}}\n\n' + b'event: message_delta\ndata: {"type":"message_delta",' + b'"delta":{"stop_reason":"tool_use"}}\n\n' +) + +_REQUEST = { + "model": "claude-sonnet-4-5", + "max_tokens": 4096, + "stream": True, + "system": [{"type": "text", "text": "You are terse."}], + "messages": [{"role": "user", "content": "What is the rate in Paris?"}], +} + + +def _served_span(telemetry, exporter): + """One gateway call, teed exactly as the router tees a live stream.""" + from uipath_claude_sdk.runtime.gateway.records import UsageTee + + tee = UsageTee() + tee.feed(_SSE_TURN) + record = make_gateway_record(input_tokens=None, output_tokens=None) + record.request_body = _REQUEST + tee.apply(record) + telemetry.on_gateway_call(record) + telemetry.flush_gateway_calls() + return exporter.get_finished_spans()[-1] + + +def test_the_llm_span_carries_the_prompt(telemetry, exporter): + """A trace with no prompt cannot answer what the model was asked.""" + a = attrs(_served_span(telemetry, exporter)) + assert a["llm.input_messages.0.message.role"] == "system" + assert a["llm.input_messages.0.message.content"] == "You are terse." + assert a["llm.input_messages.1.message.role"] == "user" + assert a["llm.input_messages.1.message.content"] == "What is the rate in Paris?" + assert json.loads(a["llm.invocation_parameters"])["max_tokens"] == 4096 + + +def test_the_llm_span_carries_the_answer_and_its_tool_calls(telemetry, exporter): + a = attrs(_served_span(telemetry, exporter)) + assert a["llm.output_messages.0.message.role"] == "assistant" + assert a["llm.output_messages.0.message.content"] == "Looking that up" + assert ( + a["llm.output_messages.0.message.tool_calls.0.tool_call.function.name"] + == "get_rate" + ) + arguments = a[ + "llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments" + ] + assert json.loads(arguments) == {"city": "Paris"} + assert a["llm.stop_reason"] == "tool_use" + + +def test_usage_still_comes_from_the_stream(telemetry, exporter): + """Accumulating content must not disturb the counters sharing that stream.""" + a = attrs(_served_span(telemetry, exporter)) + assert a["llm.token_count.prompt"] == 11 + assert a["llm.token_count.completion"] == 2 + + +def test_the_llm_span_nests_under_the_turn_it_served(telemetry, exporter, provider): + """An orphan LLM span sorts by start time and reads as unrelated work. + + The shim serves calls on its own task and the spans are built after the + turn ends, so the agent span has to be carried over deliberately. + """ + from opentelemetry import context as context_api + + parent_tracer = provider.get_tracer("test-agent") + with parent_tracer.start_as_current_span("AgentTurn") as agent_span: + telemetry.adopt_parent(context_api.get_current()) + expected = agent_span.get_span_context().span_id + telemetry.on_gateway_call(make_gateway_record()) + telemetry.flush_gateway_calls() + + llm = next(s for s in exporter.get_finished_spans() if s.name == LLM_SPAN_NAME) + assert llm.parent is not None, "The LLM span was emitted without a parent." + assert llm.parent.span_id == expected diff --git a/packages/uipath-claude-sdk/uv.lock b/packages/uipath-claude-sdk/uv.lock new file mode 100644 index 00000000..08e7e6a5 --- /dev/null +++ b/packages/uipath-claude-sdk/uv.lock @@ -0,0 +1,2797 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + +[options.exclude-newer-package] +uipath = false +uipath-platform = false +uipath-runtime = false +claude-agent-sdk = false +openinference-instrumentation-claude-agent-sdk = false +uipath-core = false + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[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.14.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/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "applicationinsights" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/f2/46a75ac6096d60da0e71a068015b610206e697de01fa2fb5bba8564b0798/applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3", size = 44722, upload-time = "2021-04-22T23:22:45.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/0d/cb6b23164eb55eebaa5f9f302dfe557cfa751bd7b2779863f1abd0343b6b/applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea", size = 55068, upload-time = "2021-04-22T23:22:44.451Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[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 = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/50/269986277f852cc83029bccbdcdc0b343a685cfd570599e58029792808d8/botocore-1.43.40.tar.gz", hash = "sha256:2085a4314cfd2c8bc1d08ab8039f76c92e99278db0d2a0e2437010526d5d5d70", size = 15639899, upload-time = "2026-07-03T00:28:16.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/8c/5f7e73fd66b28f0705bc55d7060d41ef72328b656b86ca53e75765b3ba2c/botocore-1.43.40-py3-none-any.whl", hash = "sha256:0bc9d352267c9e48415c5d7bb61ff05c3f193eac2fc7e69cfd229a05fbab67d6", size = 15323870, upload-time = "2026-07-03T00:28:12.56Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[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 = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.132" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/1d/dbf95d13ff3d7dab474035ba5a58b94bb75d2f9b78eef5b1c578d9da20d3/claude_agent_sdk-0.2.132.tar.gz", hash = "sha256:efc54a6fac96232105a40b6aed870a482dea221151a3b3356c5c514a5844cf7f", size = 312206, upload-time = "2026-08-07T04:14:22.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/2c/77d9e3d03884b1ed0f34cfc6766960c2aed096de8bace1eef593ab900fbd/claude_agent_sdk-0.2.132-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9162fd2675f366d376529b017fdff6dc522658f88c5f3c49afdd45d77228a4ef", size = 80319863, upload-time = "2026-08-07T04:14:27.073Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/6644d29b1fafa25ef4872aebd0cd46577aff4674ad0faf48e1a566332694/claude_agent_sdk-0.2.132-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1465ca52437921be79dfe1a4e5e1d35a2f850d5dbe5025256729de9befa7a62b", size = 85489278, upload-time = "2026-08-07T04:14:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/94/67/7f2ac654b919c99f16e68aaeb8bd19f8bac06cedd79c39362b3d5e547ccc/claude_agent_sdk-0.2.132-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:cd13af6ee4240b48fb01831a41248f57f3c83d235f218704f9a14e8c29af8a61", size = 89830986, upload-time = "2026-08-07T04:14:36.192Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/7306f398ac3f605211b13c4b02119e91e31c0c566a37ec8e4db243a5b6c7/claude_agent_sdk-0.2.132-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:a1085b8be7fcda441cfb1c86966e008ba72d4d2f6e20da5466bebb15c4765b55", size = 90914461, upload-time = "2026-08-07T04:14:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/95/d4/7296a1533233a1542dbdb7844efc81f3106eee6f037b0d542cecfd16a3b8/claude_agent_sdk-0.2.132-py3-none-win_amd64.whl", hash = "sha256:4860d059b89d0394acce8a7e767ea1b2da4fa73c07cf5c0ccdd86aa7ad5173ba", size = 90506408, upload-time = "2026-08-07T04:14:45.582Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "graphtty" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/b3/0756e1b1e46b61a4db4a463a76ca113e62113742f97ac6cf383dd05d6a97/graphtty-0.1.8.tar.gz", hash = "sha256:069cd84764cc64d414928451071fc9c97c05becbf564c2f0278691a6451a8240", size = 638011, upload-time = "2026-02-15T12:47:17.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/a9/66d01580a4a92b576c056e9967d552a28ed836540eaf73b436474c514bc2/graphtty-0.1.8-py3-none-any.whl", hash = "sha256:4e19e6d66b9ef79e2715377163f61a6542b5b9ee00d50406b80a40d0ba094f67", size = 25474, upload-time = "2026-02-15T12:47:16.377Z" }, +] + +[[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.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "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 = "librt" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +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/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[[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 = "mermaid-builder" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/57/d83e7e62de05adbba2476b7596ed4a253907a4f4c19be74c40e7ce2944dc/mermaid_builder-0.0.3.tar.gz", hash = "sha256:3016ef25668bcb28de4a4746aabdfaa531c06a7e7f89fa1596e6a274c7c73705", size = 7183, upload-time = "2024-06-14T17:14:51.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/4e/7480454b7ee7de5c2c8561e83b4564196ecf1c9847e893dfbb51032e5a3b/mermaid_builder-0.0.3-py3-none-any.whl", hash = "sha256:decd7d25268bda7a520ce0cab7873e50fac1e33b41e5a1dd7daec5c0ae3c7ffb", size = 6696, upload-time = "2024-06-14T17:14:38.48Z" }, +] + +[[package]] +name = "mockito" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/8e/eff6dfa2c597ddc09d2c83a922f5154f00fa8bda90f68eda984a6d7506bd/mockito-2.0.4.tar.gz", hash = "sha256:d341f228c5b5ccf3c473751f6c6f09d8d83c5da49846a6ee31eb60b65607e0ef", size = 218582, upload-time = "2026-04-15T09:23:47.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/70/625563b5925ee5393a971f4790aab4b0059deda3bf9196c57ba7172e9ab3/mockito-2.0.4-py3-none-any.whl", hash = "sha256:04d7e6e9b9b7288e76235b894f9b3cbfdc9cfc631e548ed607933146ae9db1f5", size = 51921, upload-time = "2026-04-15T09:23:46.68Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openinference-instrumentation" +version = "0.1.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/54/ad2d006229e65501eb8895d65b89a27a1330833eda41bc6e407b991aa385/openinference_instrumentation-0.1.56.tar.gz", hash = "sha256:c43bea7f1f4460fe13c3c88aff092500cc3397aaffea37188d72246489dcbc9f", size = 39137, upload-time = "2026-07-31T06:23:43.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/09/25fc4ec6f7b05948bb8bf35810805f494ecea67d3c8c3e0befe3c477aa54/openinference_instrumentation-0.1.56-py3-none-any.whl", hash = "sha256:ae7ae74bd93c0f733cf2649056c94246e1a2d0432ddf9b675dd4e58718094b62", size = 46892, upload-time = "2026-07-31T06:23:41.955Z" }, +] + +[[package]] +name = "openinference-instrumentation-claude-agent-sdk" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/20/b7730b9c809fdc793e1d99a2c33ded4d5f19b6bd3cdbba48e4756e392220/openinference_instrumentation_claude_agent_sdk-0.1.9.tar.gz", hash = "sha256:f8c63697b5c0e20ff9a4495346110fc77a1b597665dab0e0a1de91d03e669c4f", size = 16185, upload-time = "2026-08-07T14:29:22.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/56/e1127088085e58d9e8832538e05394a8b8536171106b76e536313d6eaf3b/openinference_instrumentation_claude_agent_sdk-0.1.9-py3-none-any.whl", hash = "sha256:ebfec2ce1efba7c0402aab57cec978f2f61cc555c5b4d21032be08bbda7bd4a7", size = 17745, upload-time = "2026-08-07T14:29:20.899Z" }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/3e/8f96e000651a01c801d98736c63aaf4140dd5a68a325cad92045c53484dd/openinference_semantic_conventions-0.1.31.tar.gz", hash = "sha256:39bed2e6edabb5b8dd983e22d3d4914e660ff1f8a49d7df8ceae938af5bb5836", size = 13966, upload-time = "2026-08-01T16:13:21.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d5/9d32686b8911ca79001dd0a352e89069a0b3749446b37859494805dd1bf1/openinference_semantic_conventions-0.1.31-py3-none-any.whl", hash = "sha256:ae7eee916622dc9f64ea14cae26911c78653b1c0d7a62a56788934d5830f3472", size = 11241, upload-time = "2026-08-01T16:13:20.153Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.64b0" +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/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/0c/cb9fe342de5299c7af24582eb7d788661cc53a1c4b904da92309caaa9417/opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b", size = 35880, upload-time = "2026-06-24T15:18:17.277Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.43.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/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.64b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathlib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298, upload-time = "2014-09-03T15:41:57.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[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.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-function-models" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/d7/6afa9458be0e522b9332995cf15afacda5bdd081efe2965812d16b251cf4/pydantic_function_models-0.1.12.tar.gz", hash = "sha256:530964fe7d77a3e153a7394bd4a6b1bbd7aa4bdfc7bc1420e58ebf815a527175", size = 5424, upload-time = "2026-04-16T08:35:10.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/ca/cc63a2ad7ddd490f77c59e37e2e88d8ad2a39bdaa8588101082b94359333/pydantic_function_models-0.1.12-py3-none-any.whl", hash = "sha256:ef009e84000870770579895c490be7397f900112ba7a36bed5d0e5b98ea4f108", size = 6969, upload-time = "2026-04-16T08:35:08.974Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pysignalr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "msgpack" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a6/ac80bd4972604c6930050e8b0eba41d6fde41cb3286087be0188a8865f55/pysignalr-1.3.0.tar.gz", hash = "sha256:ca2e4372f213d82148fa2f060f0fefd096f1f66fca1107ac05e76ec6abd4cf52", size = 16790, upload-time = "2025-04-29T21:23:38.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/54/74e35563927ab8538e1bd1404b35027861434aecf2bcc945ff30c188e56d/pysignalr-1.3.0-py3-none-any.whl", hash = "sha256:423c91b0d1dc8387f37118ac2d1dc87ed6b9e01993a04612eab8452193f40344", size = 19966, upload-time = "2025-04-29T21:23:37.513Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.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/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[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 = "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.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, +] + +[[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-engineio" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[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 = "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.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[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 = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[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 = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[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 = "uipath" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "applicationinsights" }, + { name = "click" }, + { name = "coverage" }, + { name = "graphtty" }, + { name = "httpx" }, + { name = "mermaid-builder" }, + { name = "mockito" }, + { name = "pathlib" }, + { name = "pydantic-function-models" }, + { name = "pyjwt" }, + { name = "pysignalr" }, + { name = "python-dotenv" }, + { name = "python-socketio" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, + { name = "uipath-platform" }, + { name = "uipath-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/0c/ca54825ca0e51d866d9cbc227f679331a6c98882316a7bd88632608d767e/uipath-2.14.1.tar.gz", hash = "sha256:ba8e13ca54dd9ad470550a186acb4bb27214a83bec8aa03eabc99fc591ad16db", size = 4541290, upload-time = "2026-08-06T13:02:34.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/50/9b05909e4d22c95855dc509111c774803ab8b3b1ca56853bb69a2ea90319/uipath-2.14.1-py3-none-any.whl", hash = "sha256:04100a72bad2f052a903ed15999887dcc4f7687b8bbb0e5754d3284637f36249", size = 435434, upload-time = "2026-08-06T13:02:33.02Z" }, +] + +[[package]] +name = "uipath-claude-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "botocore" }, + { name = "claude-agent-sdk" }, + { name = "httpx" }, + { name = "openinference-instrumentation-claude-agent-sdk" }, + { name = "uipath" }, + { name = "uipath-llm-client" }, + { name = "uipath-runtime" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "aiosqlite", specifier = ">=0.19.0" }, + { name = "botocore", specifier = ">=1.34.0" }, + { name = "claude-agent-sdk", specifier = ">=0.2.132,<0.3.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "openinference-instrumentation-claude-agent-sdk", specifier = ">=0.1.9,<0.2.0" }, + { name = "uipath", specifier = ">=2.14.0,<2.15.0" }, + { name = "uipath-llm-client", specifier = ">=1.17.1,<1.18.0" }, + { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "ruff", specifier = ">=0.9.4" }, +] + +[[package]] +name = "uipath-core" +version = "0.5.31" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/518fcb7d466a39c675194c492e8accbffdc7fd6b280d89b21ed11700a7bd/uipath_core-0.5.31.tar.gz", hash = "sha256:6abb443582d9a8ab6a504791296d49f524fb430f66d8dfaf6d6d1849958700bb", size = 130979, upload-time = "2026-07-15T06:56:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/37/47a4e12bc7bdcfab4bd4e8e1f2a5c083bd59fca42fd431026a3f14c642a3/uipath_core-0.5.31-py3-none-any.whl", hash = "sha256:3dff5ecb236bf46af683c34d79bb2cf458a942ec041e1cbf7b4825ae246ff00c", size = 55040, upload-time = "2026-07-15T06:56:02.446Z" }, +] + +[[package]] +name = "uipath-llm-client" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "tenacity" }, + { name = "uipath-platform" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/16/d2870687326d794481b2949618d26d47e961304bd7ee90afada632733852/uipath_llm_client-1.17.1.tar.gz", hash = "sha256:68ed07fba571a2d402d555dfea230170b8cc3a36dce6158fbcdc703f8ef033a6", size = 12519504, upload-time = "2026-07-20T08:17:48.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/56/5bc72e0e45b6e482561425ca587d30aa1dd65e541b63cb50288d0595a731/uipath_llm_client-1.17.1-py3-none-any.whl", hash = "sha256:1d6bb97693d1b32c60c89fa2e939a0886fa67a4a985c6bcd5bdd9c2a6464abca", size = 70354, upload-time = "2026-07-20T08:17:46.038Z" }, +] + +[[package]] +name = "uipath-platform" +version = "0.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "pydantic-function-models" }, + { name = "sqlparse" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/ff/df30f2b271cf751ee0bd8229cac1f51013957b0ea1c7e51a733a0a3dd77d/uipath_platform-0.2.17.tar.gz", hash = "sha256:b9f54531a516428dae056ff171cb2a6b231f04a484e832bed80442d69eb4c305", size = 435023, upload-time = "2026-08-03T19:01:50.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b3/12f4c11b1e1ce2c51c6eb85a1c3cb92bbbbfe3d5979a212d586c048e623e/uipath_platform-0.2.17-py3-none-any.whl", hash = "sha256:d190652d2504712ff823322b53f6de0179778acbbc81bd0fe9ef1b6757e36afb", size = 285508, upload-time = "2026-08-03T19:01:48.601Z" }, +] + +[[package]] +name = "uipath-runtime" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "uipath-core" }, + { name = "vadersentiment" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/c0/d55cf48ee43758c2c1c65f52fd0596fd75f5bd5067a5016e6553b4d2c5fe/uipath_runtime-0.13.0.tar.gz", hash = "sha256:8ae3150df5fb0043210faacf39148789c1fb252c6a8d6bc27174c0d9ce7304f5", size = 243594, upload-time = "2026-08-04T11:19:04.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/88/9518dcbeedaa8117e5fd3d0424c4a7354bae1f3ede1b2e3f48524e4b7efc/uipath_runtime-0.13.0-py3-none-any.whl", hash = "sha256:2a7c128cf14fdbef899b272ee5995da63baeb882acc904f39ef8f8f52bcb019f", size = 96349, upload-time = "2026-08-04T11:19:03.376Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, +] + +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] diff --git a/sonar-project.properties b/sonar-project.properties index 43e8b7b1..fe9b78ff 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,11 +2,11 @@ sonar.projectKey=UiPath_uipath-integrations-python sonar.organization=ui sonar.host.url=https://sonarcloud.io -sonar.sources=packages/uipath-llamaindex/src,packages/uipath-openai-agents/src,packages/uipath-google-adk/src,packages/uipath-pydantic-ai/src,packages/uipath-agent-framework/src -sonar.tests=packages/uipath-llamaindex/tests,packages/uipath-openai-agents/tests,packages/uipath-google-adk/tests,packages/uipath-pydantic-ai/tests,packages/uipath-agent-framework/tests +sonar.sources=packages/uipath-llamaindex/src,packages/uipath-openai-agents/src,packages/uipath-google-adk/src,packages/uipath-pydantic-ai/src,packages/uipath-agent-framework/src,packages/uipath-claude-sdk/src +sonar.tests=packages/uipath-llamaindex/tests,packages/uipath-openai-agents/tests,packages/uipath-google-adk/tests,packages/uipath-pydantic-ai/tests,packages/uipath-agent-framework/tests,packages/uipath-claude-sdk/tests sonar.python.version=3.11,3.12,3.13 -sonar.python.coverage.reportPaths=packages/uipath-llamaindex/coverage.xml,packages/uipath-openai-agents/coverage.xml,packages/uipath-google-adk/coverage.xml,packages/uipath-pydantic-ai/coverage.xml,packages/uipath-agent-framework/coverage.xml +sonar.python.coverage.reportPaths=packages/uipath-llamaindex/coverage.xml,packages/uipath-openai-agents/coverage.xml,packages/uipath-google-adk/coverage.xml,packages/uipath-pydantic-ai/coverage.xml,packages/uipath-agent-framework/coverage.xml,packages/uipath-claude-sdk/coverage.xml sonar.exclusions=**/samples/**,**/testcases/**,**/template/**