diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 000000000..c1005e8a8 --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,93 @@ +# MCP server integration tests. +# +# Runs the tests/integration_mcp suite via the shared acceptance harness (same workspace, +# auth, and Model Serving endpoint as the anomaly/integration suites). The suite deploys an +# isolated MCP app from the bundle, drives it as an agent would, and tears it down — see +# tests/integration_mcp/conftest.py. No MCP-specific secrets are needed; auth comes from the +# acceptance action exactly like the other integration workflows. +name: mcp + +on: + pull_request: + types: [opened, synchronize, ready_for_review] + paths: + - "mcp-server/**" + - "src/databricks/labs/dqx/**" # the runner installs the in-repo DQX wheel + - "tests/integration_mcp/**" + - ".github/workflows/mcp.yml" + merge_group: + types: [checks_requested] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false # let teardown finish so the isolated app/bundle is removed + +jobs: + # Gate downstream work: fork PRs (no access to the tool environment) and drafts do not run. + not-a-fork: + runs-on: + group: databrickslabs-protected-runner-group + labels: linux-ubuntu-latest + if: github.event_name == 'pull_request' && !github.event.pull_request.draft && !github.event.pull_request.head.repo.fork + steps: + - run: echo "Not a fork PR, proceeding" + + mcp-tests: + needs: not-a-fork + environment: tool + runs-on: + group: larger-runners + labels: larger + permissions: + id-token: write + pull-requests: write + steps: + - name: Checkout Code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup environment + uses: ./.github/actions/setup-env + + - name: Pre-build DQX wheel + uses: ./.github/actions/prebuild-wheel + + - name: Prepare code coverage configuration for MCP tests + run: | + cat > tests/integration_mcp/.coveragerc << EOF + [run] + source = ../../src + relative_files = true + parallel = true + EOF + + # Required for the DAB (Databricks Asset Bundle) deploy the MCP suite runs (ci_deploy.sh). + # The CLI lands on PATH (via GITHUB_PATH) for the acceptance action's test subprocess. + - name: Install Databricks CLI + uses: databricks/setup-cli@596b0a354ba14aa59921aca1b02bd67c2b0a81a5 # v0.297.2 + + - name: Run MCP integration tests + uses: databrickslabs/sandbox/acceptance@83461e5dd7021feabb1a9ca3ee10d6f46b72092a # acceptance/v0.4.6 + with: + vault_uri: ${{ secrets.VAULT_URI }} + timeout: 1h + codegen_path: tests/integration_mcp/.codegen.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }} + ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }} + COVERAGE_FILE: ${{ github.workspace }}/.coverage # make sure the coverage report is preserved + + - name: Merge coverage reports and convert them to XML + run: make combine-coverage + + - name: Publish test coverage + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 + with: + use_oidc: true + files: coverage-combined.xml + flags: mcp diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index afcab47cd..1f28818a3 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -158,3 +158,20 @@ jobs: - name: Run app backend tests run: make app-test working-directory: . + + mcp: + needs: not-a-fork + name: Build and Check MCP Server + runs-on: + group: databrickslabs-protected-runner-group + labels: linux-ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup environment + uses: ./.github/actions/setup-env + + - name: Run MCP server tests + run: make mcp-test diff --git a/Makefile b/Makefile index d500170a9..7f43a9c1e 100644 --- a/Makefile +++ b/Makefile @@ -68,6 +68,9 @@ perf: ## Run performance benchmarks (long timeout) anomaly: ## Run anomaly integration tests (long timeout, with reruns) $(UV_RUN) pytest tests/integration_anomaly/ -v -n 10 --timeout 1200 --durations 20 --reruns 2 --reruns-delay 5 +mcp-integration: ## Run MCP server integration tests (deploys an isolated app; requires workspace auth + Databricks CLI) + $(UV_RUN) pytest tests/integration_mcp/ -v --timeout 1800 --durations 10 + coverage: ## Run all tests (excl. e2e/perf) and open HTML coverage report $(UV_TEST) --ignore=tests/e2e --ignore=tests/perf --cov --cov-report=html tests/ open htmlcov/index.html @@ -177,6 +180,36 @@ app-test: ## Run app backend pytest suite (K= filter, COV=1 for coverage) $(if $(K),-k "$(K)") \ $(if $(COV),--cov=src/databricks_labs_dqx_app/backend --cov-report=term-missing --cov-report=xml:coverage-app.xml) +# Run the MCP server's unit-test suite (pytest, no Databricks/Spark dependencies). +# Usage: make mcp-test # run everything +# make mcp-test K=expr # forward -k filter to pytest +# +# pytest is not declared in mcp-server's pyproject/uv.lock — the suite only +# needs anyio, which ships transitively. With UV_FROZEN=1 we can't add pytest +# to the lock from here, so inject it ephemerally with ``uv run --with pytest`` +# over the synced runtime env (anyio's pytest plugin handles the async tests). +mcp-test: ## Run MCP server pytest suite (K= filter) + cd mcp-server && uv run --with pytest pytest tests/ \ + $(if $(K),-k "$(K)") + +# One-command MCP server deploy (parity with app-deploy). Deploys the bundle (app + runner +# job + setup job), runs the one-time setup job (UC grants + temp-schema ownership), then +# deploys & starts the app via ``bundle run``. The catalog-name secret must already be set +# (see the deploy guide) — the setup job and app read it from the secret scope. +# +# Usage: make mcp-deploy PROFILE=my-profile +# make mcp-deploy PROFILE=my-profile BUNDLE_VARS='--var catalog_name=main' +# TARGET defaults to the bundle's default target (dev); override with TARGET=. +# The bundle artifact build runs `uv build ../` from inside mcp-server/, so the global +# relative UV_BUILD_CONSTRAINT (.build-constraints.txt) would resolve against the wrong +# directory. Pin it to the absolute repo-root path so the wheel build finds it. +mcp-deploy: export UV_BUILD_CONSTRAINT := $(CURDIR)/.build-constraints.txt +mcp-deploy: ## Deploy the MCP server bundle, run setup, and (re)deploy + start the app + @test -n "$(PROFILE)" || (echo "Usage: make mcp-deploy PROFILE= [TARGET=] [BUNDLE_VARS=...]"; exit 1) + cd mcp-server && databricks bundle deploy -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) $(BUNDLE_VARS) + cd mcp-server && databricks bundle run dqx_setup -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) $(BUNDLE_VARS) + cd mcp-server && databricks bundle run mcp-dqx -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) $(BUNDLE_VARS) + ##@ App deploy (require PROFILE=; most also need TARGET=) # Grant Unity Catalog permissions after bundle deploy. @@ -273,6 +306,14 @@ lock-app-dependencies: ## Regenerate app/uv.lock, app/yarn.lock, app/.build-cons uv pip compile --generate-hashes --universal --no-header - > app/build-constraints-new.txt mv app/build-constraints-new.txt app/.build-constraints.txt +# Regenerate the MCP server lockfile and scrub private-proxy URLs so the +# committed file resolves against whatever registry the install environment +# is configured for (JFrog in CI, public in fork PRs). +lock-mcp-dependencies: export UV_FROZEN := 0 +lock-mcp-dependencies: ## Regenerate mcp-server/uv.lock + cd mcp-server && uv lock --exclude-newer "7 days" + perl -pi -e 's|registry = "https://[^"]*"|registry = "https://pypi.org/simple"|g' mcp-server/uv.lock + lock-dependencies: export UV_FROZEN := 0 lock-dependencies: ## Regenerate top-level uv.lock and .build-constraints.txt uv lock --exclude-newer "7 days" diff --git a/docs/dqx/docs/guide/dqx_mcp_server.mdx b/docs/dqx/docs/guide/dqx_mcp_server.mdx new file mode 100644 index 000000000..ddd02d010 --- /dev/null +++ b/docs/dqx/docs/guide/dqx_mcp_server.mdx @@ -0,0 +1,453 @@ +--- +sidebar_position: 315 +title: DQX MCP Server +sidebar_label: DQX MCP Server +--- + +import Admonition from '@theme/Admonition'; +import Link from '@docusaurus/Link'; + +# DQX MCP Server Beta {#dqx-mcp-server} + +**DQX MCP Server** is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes DQX data quality tools for AI agents. +It runs as a [Databricks App](https://docs.databricks.com/en/dev-tools/databricks-apps/index.html) inside your workspace with on-behalf-of (OBO) authentication — every operation respects the calling user's Unity Catalog permissions. + + +The MCP server is the recommended integration point for: +- AI agents ([Agent Bricks](https://www.databricks.com/product/artificial-intelligence/agent-bricks), Genie Code) that need to profile tables, generate rules, and run quality checks programmatically. +- IDE-based workflows (Cursor, Claude Code) where you want DQX tools available as MCP tools. +- Any MCP-compatible client that needs data quality capabilities without writing DQX code directly. + +For interactive, browser-based rule management, see [DQX Studio](/docs/guide/dqx_studio). +For code-level integration into pipelines or notebooks, see the [Programmatic approach](/docs/guide/quality_checks_apply#programmatic-approach). + + +## Who this guide is for + +There are two reading paths: + +- **Platform admins** set up and operate the server: [Prerequisites](#prerequisites) → [Deploy](#deploy) → [Configuration](#configuration) → [Troubleshooting](#troubleshooting) → [Upgrade and uninstall](#upgrade-and-uninstall). +- **Users** — Genie Code users, data engineers, power users — consume it once it's deployed: [Connect an MCP client](#connect-an-mcp-client) → [Usage](#usage) → [Example prompts](#example-prompts). In **Genie Code** you just need the app shared with you and then enable it in settings; other clients (Cursor, Claude Code) use the server's `/mcp` URL — ask your admin. + +### Quickstart + +**Admins** — set the catalog, deploy, and share the URL (full steps under [Deploy](#deploy)): + +```bash +databricks secrets create-scope dqx-config --profile +databricks secrets put-secret dqx-config catalog_name --string-value "" --profile +make mcp-deploy PROFILE= +databricks apps get mcp-dqx --profile -o json | jq -r .url # users in users_group discover it in Genie Code; share /mcp for other clients +``` + +**Users** — connect and ask (details under [Connect an MCP client](#connect-an-mcp-client)): + +1. **Genie Code:** once the DQX app is shared with you (you're in its `users_group`), enable it from **Settings → MCP Servers** — no URL needed. **Cursor / Claude Code:** add a remote MCP server with the URL `https:///mcp`. +2. Ask in plain English, e.g. *"Profile `..` and tell me which rows fail data quality checks."* + +## Architecture + +```mermaid +sequenceDiagram + actor Client as MCP client + participant App as DQX MCP app + participant WH as SQL warehouse + participant Job as Runner job + + Note over Client: Genie Code · Cursor · Claude Code + Client->>App: Connect to /mcp via OAuth (user token forwarded for OBO) + + Note over Client,Job: Direct & synchronous + Client->>App: get_table_schema + App->>WH: DESCRIBE TABLE (as the user) + WH-->>App: columns + App-->>Client: result + + Note over Client,Job: Data tools — async
profile_table · run_checks · apply_checks_and_save_to_table + Client->>App: call a data tool + App->>WH: create temp view as the user (UC enforced) + App->>Job: submit run (serverless, as the app SP) + App-->>Client: run_id + + Note over Client,Job: Metadata tools — async
generate_rules · generate_rules_from_contract · validate_checks
list_available_checks · load_checks · save_checks + Client->>App: call a metadata tool + App->>Job: submit run (as the app SP) + App-->>Client: run_id + + Note over Client,Job: Poll until complete + Client->>App: get_run_result(run_id) + App->>Job: check status + Job-->>App: running… then result + App-->>Client: status / result +``` + +**Async pattern:** Long-running tools (profiling, rule generation, validation, running/applying checks, save/load) submit a Databricks job and return a `run_id` immediately — this avoids HTTP timeouts in clients like Genie Code. The client then calls `get_run_result(run_id)` to fetch the outcome. If the job is still running, `get_run_result` returns `{"status": "running"}` and the client calls it again until the run is terminal. + +**UC governance:** Tools that read data (`profile_table`, `run_checks`, `apply_checks_and_save_to_table`) create a temporary view using the **user's** OBO token. If the user cannot read the source table, view creation fails — Unity Catalog is enforced end to end. The SP runner job then reads through that view. Each view is dropped by the runner job itself when the operation finishes (a periodic sweeper reaps any orphans as a backstop), so cleanup does not depend on the client polling for results. + +## Available tools + +| Tool | Description | Execution | Returns | +|------|-------------|-----------|---------| +| `get_workflow` | Get the recommended tool-call sequence | In-process | Workflow JSON | +| `get_table_schema` | Get column names, types, and comments | Direct SQL via OBO | Result directly | +| `profile_table` | Profile data patterns (nulls, ranges, distributions) | OBO view + runner job | `run_id` | +| `generate_rules` | Generate DQX check rules from a profile | Runner job | `run_id` | +| `generate_rules_from_contract` | Generate DQX checks from an ODCS data contract | Runner job | `run_id` | +| `list_available_checks` | List the built-in check functions | Runner job | `run_id` | +| `validate_checks` | Validate check definitions for correctness | Runner job | `run_id` | +| `run_checks` | Execute checks and return a sample of results | OBO view + runner job | `run_id` | +| `save_checks` | Persist checks to a table, UC volume, or workspace file † | Runner job (writes as SP) | `run_id` | +| `load_checks` | Load previously saved checks | Runner job (reads as SP) | `run_id` | +| `apply_checks_and_save_to_table` | Apply checks and write valid / quarantine rows to Delta † | OBO view + runner job (writes as SP) | `run_id` | +| `get_run_result` | Fetch the result (or `running`/`failed` status) of a submitted run | Job status check | Result or status | + +† These tools **write** as the app service principal, so the SP needs write access to the target location — see [Write access for the persisting tools](#write-access). + +## Prerequisites + +Tooling: + +- [Databricks CLI](https://docs.databricks.com/dev-tools/cli/install.html) **0.279.0+** (the bundle uses the [direct deployment engine](https://docs.databricks.com/aws/en/dev-tools/bundles/direct), which needs this version). Check with `databricks --version`. + +Workspace features (ask a workspace admin to enable any that are missing): + +| Feature | Why it's needed | +|---|---| +| **Databricks Apps** | The MCP server runs as a Databricks App. | +| **Serverless compute** | The app and the runner/setup jobs run on serverless. | +| **Unity Catalog** | All data access and grants are UC-governed (OBO). | + +Permissions for the **deploying** user (typical names; exact entitlements vary by workspace — if a step is rejected, this table tells you which grant is missing): + +| Permission | On | Used by | Symptom if missing | +|---|---|---|---| +| Allow to **create/manage Apps** | workspace | `bundle deploy` (the app) | app creation rejected | +| **Allow cluster create** / serverless entitlement | workspace | runner + setup jobs | job creation rejected | +| **Manage** a secret scope (or an existing one) | workspace | the `catalog_name` secret | `secrets put-secret` rejected | +| **USE CATALOG** + **CREATE SCHEMA** | the target catalog | setup job (creates `.tmp`) | `User does not have CREATE SCHEMA` | +| **MANAGE** (or owner) on the catalog | the target catalog | setup job's `GRANT` / `ALTER SCHEMA OWNER` | grant rejected | + +An existing Unity Catalog **catalog** must already exist (the setup job creates a schema inside it, not the catalog itself). + +## Deploy + +### What gets deployed + +Deploying the bundle creates four things in your workspace (all prefixed by `name_prefix`, default `mcp-dqx`): + +| Resource | Name | Purpose | +|---|---|---| +| Databricks App | `mcp-dqx` | The MCP server (serves `/mcp`); runs as its own app service principal. | +| Job | `mcp-dqx-runner` | Serverless job with `databricks-labs-dqx` installed; tools submit work to it. | +| Job | `mcp-dqx-setup` | One-time setup: creates the temp schema + UC grants. | +| Secret | `dqx-config/catalog_name` | The catalog the app/runner use for temp views (you set this). | + +The runner and setup jobs are idempotent and safe to re-run. Nothing here is destroy-protected, so `databricks bundle destroy` removes it all — see [Upgrade and uninstall](#upgrade-and-uninstall). + +### 1. Authenticate + +```bash +databricks auth login --host https:// --profile +``` + +### 2. Configure the catalog name (one-time) + +The catalog name is stored as a Databricks secret. Both the app and the setup job read from it. + +```bash +# Create the secret scope (one-time) +databricks secrets create-scope dqx-config --profile + +# Set the catalog name +databricks secrets put-secret dqx-config catalog_name --string-value "" --profile +``` + +To change the catalog later, update the secret and restart the app. + +### 3. Deploy + +**One command** (parity with the Studio app's `make app-deploy`): + +```bash +make mcp-deploy PROFILE= +``` + +This runs `bundle deploy`, the one-time setup job, and then deploys + starts the app. Add `BUNDLE_VARS='--var catalog_name='` to override the catalog, or `TARGET=` for a non-default bundle target. + +**Or step by step** (what `make mcp-deploy` runs): + +```bash +cd mcp-server + +# Deploy the bundle (app + runner job + setup job) +databricks bundle deploy --profile + +# Run the one-time setup job (idempotent) +databricks bundle run dqx_setup --profile + +# Deploy and start the app +databricks bundle run mcp-dqx --profile +``` + +The **setup job** reads the catalog name from the secret and: +- Creates the `.tmp` schema for temporary views +- Grants `USE CATALOG` on the catalog to all users and the app SP +- Grants `USE SCHEMA` + `CREATE TABLE` on the tmp schema to all users +- Grants `USE SCHEMA` + `SELECT` on the tmp schema to the app SP, and makes the SP the schema owner (so it can drop the OBO-created temp views) + +### 4. Find your MCP endpoint + +The MCP endpoint is `https:///mcp`. Find `` in the Databricks UI under **Compute → Apps → mcp-dqx** (the **App URL**), or via the CLI: + +```bash +databricks apps get mcp-dqx --profile -o json | jq -r .url +``` + +## Configuration + +### Security and governance + +- **Reads run as the calling user.** `get_table_schema`, and the temporary views behind `profile_table` / `run_checks` / `apply_checks_and_save_to_table`, use the user's OBO token — Unity Catalog is enforced, so a user only ever sees data they are already granted. +- **Jobs and writes run as the app service principal.** The runner job executes under the app SP. `save_checks` and `apply_checks_and_save_to_table` write as the SP, so it needs write grants on the target (see [Write access](#write-access)); the tables they create are auto-granted to the calling user (`ALL PRIVILEGES` + `MANAGE`, so the user can fully manage them while the SP retains ownership). +- **App access is gated.** Only members of `users_group` (default `account users`) receive `CAN_USE` on the app, and the Databricks Apps front-door requires an OAuth login (PATs are rejected). + +### Secrets + +| Secret Scope | Key | Description | +|-------------|-----|-------------| +| `dqx-config` | `catalog_name` | UC catalog for temp views | + +### Bundle variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `name_prefix` | Prefix for all resource names (app + jobs). Override to deploy an isolated second copy. | `mcp-dqx` | +| `catalog_name` | Catalog for temp views. Empty ⇒ read from the secret scope (the normal case). | `""` | +| `config_secret_scope` | Secret scope holding `catalog_name`. | `dqx-config` | +| `users_group` | Group granted `CAN_USE` on the app and access to the temp schema. | `account users` | + +Override at deploy time (or via `BUNDLE_VARS=` with `make mcp-deploy`): + +```bash +databricks bundle deploy --var users_group="users" --profile +``` + +### Monitoring + +```bash +databricks apps get mcp-dqx --profile # status (compute + app state, URL) +databricks apps logs mcp-dqx --profile # tail app logs +databricks apps stop mcp-dqx --profile # stop the app (e.g. to pause cost) +``` + +The runner job's per-operation runs are visible under **Workflows → Jobs → mcp-dqx-runner**, which is where tool failures (a failed `run_id`) show their full stack trace. + +**Cost and concurrency:** the app runs continuously while deployed (stop it with `databricks apps stop` to pause that cost); the runner jobs are serverless and bill per run, and support up to 10 concurrent runs. + +### How it works + +1. **Catalog name:** Read from Databricks secret (`dqx-config/catalog_name`) by both the app and the setup job. Update the secret and restart the app to change it. +2. **Async job execution:** Tools trigger the pre-deployed `mcp-dqx-runner` job via `run_now()`, which uses a serverless environment with `databricks-labs-dqx` pre-installed. Jobs support up to 10 concurrent runs. +3. **SQL warehouse:** Auto-discovered at runtime from the user's available warehouses. No configuration needed. +4. **App SP permissions:** Granted automatically by the setup job and the bundle's resource bindings. + +### Write access for the persisting tools {#write-access} + + +Most tools read through the calling user's OBO token, but the two tools that **persist** data — `save_checks` and `apply_checks_and_save_to_table` — write as the **app service principal**. The setup job only grants the SP read access on the temp schema, so writes to your own catalogs/schemas will fail with a permission error until you grant the SP write access to the target location: + +```sql +-- Replace with the app service principal's application ID +-- (Compute > Apps > your app > Authorization), and . with your target. +GRANT USE CATALOG ON CATALOG `` TO ``; +GRANT USE SCHEMA, CREATE TABLE, MODIFY ON SCHEMA ``.`` TO ``; +``` + +For a UC **volume** or **workspace file** target, grant the SP the equivalent write permission on that volume/folder instead. If you only ever use the read/analyze tools (profile, generate, validate, run), no extra grant is needed. + +Tables these tools create are **owned by the app service principal**, but the **calling user is automatically granted `ALL PRIVILEGES` + `MANAGE`** on them — so you can read, modify, **and drop/alter** the outputs outside the MCP. Ownership deliberately stays with the SP (so repeat overwrite runs keep working and don't depend on a grant you could revoke); if you also want to *own* the tables, point the tools at a schema you own. + + +## Connect an MCP client + +The app exposes a standard MCP endpoint over Streamable HTTP at `https:///mcp`. **Genie Code** (inside Databricks) is the recommended client — it authenticates to the app for you. External IDE clients such as Cursor and Claude Code point at the same URL and complete the Databricks OAuth login; follow each tool's own MCP documentation to add a remote server. + + +The Databricks Apps front-door authenticates the caller with **OAuth** and forwards the user's token to the server (on-behalf-of). A **personal access token (PAT) is rejected** by the OBO front-door. Genie Code supplies an OAuth token automatically; external clients complete the Databricks OAuth login flow on first use. Every tool then runs as **you**, so it only sees data your Unity Catalog grants allow. + + +### Genie Code (recommended) + +Genie Code runs inside your Databricks workspace and authenticates to the app automatically. Because the DQX server is a Databricks App, you don't need its URL — once the app is **shared with you** (your account is in its `users_group`; the deploy grants `CAN_USE` to that group, default `account users`), you discover and enable it from **Settings → MCP Servers** in Genie Code. See the Databricks guide [Add MCP servers to Genie Code](https://docs.databricks.com/aws/en/genie-code/mcp) for the exact steps. + +Once enabled, the DQX tools are available and you can ask questions in plain English (see [Example prompts](#example-prompts)). + +![Genie Code Settings showing the deployed DQX MCP server with its tools enabled](/img/mcp-genie-settings.png) + +#### Approving tool actions + +The **Actions** setting sets the approval mode for new conversations (you can also override it per conversation). With **Ask first**, you approve each tool before it runs; with **Auto-approve**, tools run automatically (Databricks blocks potentially risky actions). The DQX workflow polls `get_run_result` repeatedly while jobs run, so **Auto-approve** avoids repeated approval prompts for those status checks. See the Databricks guide [Approve tool actions](https://docs.databricks.com/aws/en/genie-code/use-genie-code#approve-tool-actions). + +![A Genie Code conversation calling a DQX tool, showing the per-conversation approval control](/img/mcp-genie-auto-approve.png) + +### Cursor + +Add a remote (HTTP) MCP server pointing at `https:///mcp`, following [Cursor's MCP documentation](https://cursor.com/docs/mcp#installing-mcp-servers). On first use Cursor runs the Databricks OAuth login for the app (a PAT will not work — see the note above). + +### Claude Code + +Add a remote (HTTP) MCP server pointing at `https:///mcp`, following [Claude Code's MCP documentation](https://code.claude.com/docs/en/mcp#installing-mcp-servers). Authenticate to the app via the Databricks OAuth login on first use. + +## Usage + +### Recommended workflow + +You don't run these tools by hand — you ask in plain English (see [Example prompts](#example-prompts)) and the assistant orchestrates them. Behind a request, it calls `get_workflow` to discover the recommended sequence and then runs roughly: + +1. `get_table_schema` — Understand the table structure (returns result directly) +2. `profile_table` — Profile data to discover patterns (returns `run_id`) +3. `get_run_result(run_id)` — Retrieve profiling results +4. `generate_rules` — Convert the profile into check rules (returns `run_id`) +5. `get_run_result(run_id)` — Retrieve generated rules +6. `validate_checks` — Validate rules before execution (optional, returns `run_id`) +7. `get_run_result(run_id)` — Retrieve validation status +8. `run_checks` — Execute rules and get a sample of results (returns `run_id`) +9. `get_run_result(run_id)` — Retrieve check results + +Each long-running tool returns a `run_id` immediately; the client then polls `get_run_result(run_id)` until `status` is `completed` or `failed`. Several `get_run_result` calls per run are expected while a job runs — see [Troubleshooting](#troubleshooting). + +**Alternative entry points:** +- If an [ODCS data contract](/docs/guide/data_contract_quality_rules_generation) already exists, use `generate_rules_from_contract` instead of profiling + `generate_rules` — it derives checks deterministically from the contract's schema and quality expectations. +- To reuse rules across sessions, `save_checks` to persist them and `load_checks` to retrieve them later. +- To operationalize (write results to Delta instead of returning a sample), use `apply_checks_and_save_to_table` instead of `run_checks`. +- For **scheduled pipelines**, treat the MCP server as the interactive/exploratory path: use it to design and `save_checks` a validated rule set, then run that rule set in production with the [programmatic approach](/docs/guide/quality_checks_apply#programmatic-approach). + +### Example prompts + +These are natural-language prompts you can give any connected agent. Replace `..
` with your own fully qualified table. + +| Tool | Example prompt | +|------|----------------| +| `get_workflow` | "What's the recommended workflow for running DQX data quality checks on a table?" | +| `get_table_schema` | "What columns does `..
` have?" | +| `profile_table` | "Profile `..
` using all rows (pass options `{"sample_fraction": 1.0}`) and show the column statistics." | +| `generate_rules` | "From that profile, generate DQX rules with `error` criticality." | +| `list_available_checks` | "What built-in DQX check functions can I use in rules?" | +| `generate_rules_from_contract` | "Generate DQX checks from the data contract at `/Volumes////contract.yaml`." | +| `validate_checks` | "Validate these checks before I run them." | +| `run_checks` | "Run those checks against `..
` and show which rows fail and why." | +| `save_checks` | "Save those checks to `..
_checks` so the team can reuse them." | +| `load_checks` | "Load the data quality rules we saved earlier from `..
_checks` and run them on today's data." | +| `apply_checks_and_save_to_table` | "Apply the checks to `..
`, writing valid rows to `..
_clean` and invalid rows to `..
_quarantine`." | +| `get_run_result` | "Get the result for run_id ``." | + + +`profile_table` samples the data by default (good for large tables, but on a tiny table it can miss issues). Pass `{"sample_fraction": 1.0}` in the tool's `options` to profile every row. + + +### Run the whole workflow in one prompt + +You don't need to name the tools — the assistant selects them from a plain-English goal. The request below is the kind of thing an analyst onboarding a new table would actually ask, and it naturally drives the full chain: schema inspection, profiling, rule generation (from both the data and a contract), validation, running checks, persisting the rules, reloading them the way a pipeline would, and writing clean/quarantine tables. It assumes the sample table from [Try it with sample data](#try-it-with-sample-data); swap in your own table, contract path, and output tables. + +```text +I've just been handed the table ..customers and I need to get a handle on +its data quality before the team starts using it — I'm fairly new to DQX. Can you: + +- take a look at the table and tell me what kinds of quality checks DQX could enforce on it; +- scan the whole table (don't just sample it) and find where the data is actually dirty; +- set up a sensible set of rules to catch those problems — we also have a data contract for + this table at /Volumes////customers_contract.yaml, so use that + too if it helps; +- run the rules and show me which records fail and why; +- once the rules look right, save them somewhere the team can reuse them; +- then, the way our nightly pipeline would, load those saved rules back and use them to + produce a clean table and a separate quarantine table of the rejected rows; +- and finally, summarise in plain English how bad the data is and what I should fix first. +``` + +This reuse framing is also the point of `load_checks`: the MCP server is stateless, so saved checks live only in whatever durable location you write them to. A fresh session (or a scheduled pipeline) starts with no checks in context — `load_checks` pulls a previously-saved set back so `run_checks` / `apply_checks_and_save_to_table` can use it without re-profiling. + + +Saving the rules and producing the clean/quarantine tables run as the app service principal. If they fail with a permission error, grant the SP write access on the target schema — see [Write access for the persisting tools](#write-access). + + +### Try it with sample data + +To follow the example above end to end, create a small table with deliberate data-quality issues (NULL/duplicate id, empty name, invalid email, out-of-range age, negative amount). Run this in a SQL editor or notebook against a catalog/schema you can write to: + +```sql +CREATE SCHEMA IF NOT EXISTS .; + +CREATE OR REPLACE TABLE ..customers ( + customer_id INT, name STRING, email STRING, age INT, + country STRING, signup_date DATE, amount DOUBLE +); + +INSERT INTO ..customers VALUES + (1, 'Alice', 'alice@example.com', 34, 'US', DATE'2023-01-15', 120.50), + (2, 'Bob', 'bob@example.com', 41, 'UK', DATE'2023-02-20', 88.00), + (3, 'Charlie', 'charlie@example.com', 29, 'DE', DATE'2023-03-10', 45.25), + (4, NULL, 'dora@example.com', 52, 'US', DATE'2023-04-01', 200.00), -- null name + (5, 'Eve', 'not-an-email', 38, 'FR', DATE'2023-05-05', 60.00), -- bad email + (7, 'Grace', 'grace@example.com', -3, 'IN', DATE'2023-07-08', 30.00), -- negative age + (8, 'Heidi', 'heidi@example.com', 210, 'US', DATE'2023-08-19', 95.00), -- impossible age + (9, 'Ivan', 'ivan@example.com', 33, NULL, DATE'2023-09-22', -15.00), -- null country, negative amount + (3, 'Charlie', 'charlie@example.com', 29, 'DE', DATE'2023-03-10', 45.25), -- duplicate id + (NULL, 'Peggy', 'peggy@example.com', 39, 'US', DATE'2024-01-05', 180.00); -- null id +``` + +Optionally, to try `generate_rules_from_contract`, save an [ODCS data contract](/docs/guide/data_contract_quality_rules_generation) describing this table to a UC volume or workspace path and reference it in the prompt. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `bundle deploy`: `databricks: command not found` | CLI missing / too old | Install Databricks CLI 0.279.0+ (`databricks --version`). | +| Deploy fails creating the app | Apps not enabled, or you lack app-create permission | Ask an admin to enable Apps / grant app-create (see [Prerequisites](#prerequisites)). | +| Setup job: `CREATE SCHEMA` or `GRANT` denied | Missing catalog permissions | Grant the deploying user `USE CATALOG` + `CREATE SCHEMA` + `MANAGE` on the catalog. | +| MCP client gets **401** on `/mcp` | Using a PAT, or not authenticated | Connect with **OAuth** — Genie Code does this automatically; external clients run the Databricks OAuth login. PATs are rejected by the OBO front-door. | +| A read tool fails "cannot read table" | Your UC grants don't allow it (OBO is enforced) | Grant yourself `SELECT` on the source table. | +| `save_checks` / `apply_checks_and_save_to_table` fail with a permission error | The **app SP** can't write to the target | Grant the SP write access — see [Write access](#write-access). | +| A tool's `run_id` returns `failed` | The runner job errored | Open **Workflows → Jobs → mcp-dqx-runner** and read the failed run's logs. | +| `get_run_result` keeps returning `running` | Job still executing (serverless cold start ≈ 1 min) | Poll again after a short pause. | + +### Managing the server's tools + +In Genie Code, open the DQX server from **Settings → MCP Servers** to enable or disable individual tools. After the server is upgraded to a new version, use **Refresh tools list** so the client picks up any added or changed tools. + +![The DQX MCP server's available tools in Genie Code, with per-tool toggles and the Refresh tools list button](/img/mcp-genie-tools.png) + +### Repeated `get_run_result` calls are expected + +Most DQX tools run asynchronously: a tool returns a `run_id`, and the client calls `get_run_result` until the run is complete. It is normal to see several `get_run_result` calls for a single operation while the job runs. + +![A Genie Code conversation showing repeated get_run_result calls while a job completes](/img/mcp-genie-polling.png) + +Once the run completes, these steps collapse into a single summary badge with the result: + +![The completed conversation with the tool-call steps collapsed into a summary badge](/img/mcp-genie-steps-collapsed.png) + +## Upgrade and uninstall + +**Upgrade** — pull the latest code and re-deploy; the bundle updates the app and jobs in place: + +```bash +git pull +make mcp-deploy PROFILE= +``` + +**Uninstall** — none of the resources are destroy-protected: + +```bash +cd mcp-server +databricks bundle destroy --profile # removes the app + both jobs +``` + +The `.tmp` schema and the `dqx-config` secret scope are **not** part of the bundle — remove them manually if desired (`databricks secrets delete-scope dqx-config` and `DROP SCHEMA .tmp`). + +## Getting help + +- **Docs**: you are reading them. Start at the [User Guide](/docs/guide/) or [Quality Checks Reference](/docs/reference/quality_checks) when you need to know what a specific check does. +- **Issues and discussions**: file an issue or ask a question on [GitHub](https://github.com/databrickslabs/dqx). +- **Contributing**: want to fix something or suggest a feature? See the [contributing guide](/docs/dev/contributing). diff --git a/docs/dqx/docs/reference/feature_lifecycle.mdx b/docs/dqx/docs/reference/feature_lifecycle.mdx new file mode 100644 index 000000000..d5e7577ee --- /dev/null +++ b/docs/dqx/docs/reference/feature_lifecycle.mdx @@ -0,0 +1,50 @@ +--- +sidebar_position: 500.5 +title: Feature lifecycle +sidebar_label: Feature lifecycle +--- + +# Feature lifecycle + +DQX features move through release stages as they mature. When a feature is not yet generally +available, its documentation page shows a status badge next to the title — for example, the +[DQX MCP Server](/docs/guide/dqx_mcp_server) is Beta. +This page explains what each badge means. + +Features **without** a badge are [generally available](#ga). + +## Status badges + +| Badge | Stage | Summary | +|---|---|---| +| Experimental | [Experimental](#experimental) | For evaluation only; may change or be removed at any time. | +| Beta | [Beta](#beta) | Usable and feature-complete; the API may still change before GA. | +| GA | [Generally available](#ga) | Stable and production-ready (the default for unbadged features). | +| Deprecated | [Deprecated](#deprecated) | Still works but slated for removal; migrate to the replacement. | + +## Experimental {#experimental} + +The feature is available for evaluation and early feedback. Its API, behavior, and on-disk or +table formats may change — or the feature may be removed entirely — in any release, without notice +or a migration path. There are no backward-compatibility or support guarantees. Not recommended +for production workloads. + +## Beta {#beta} + +The feature is feature-complete and usable, and is released for broader adoption and feedback ahead +of general availability. The API may still change in backward-incompatible ways between releases, +and some rough edges may remain. It is suitable for non-critical workloads; if you depend on the +current behavior, pin your DQX version and review the [changelog](https://github.com/databrickslabs/dqx/blob/v0.15.0/CHANGELOG.md) +when upgrading. + +## Generally available (GA) {#ga} + +The feature is stable and production-ready, and is covered by DQX's normal +[semantic versioning](https://semver.org/) and backward-compatibility practices. This is the +default: a feature with no status badge is generally available. + +## Deprecated {#deprecated} + +The feature still works but is slated for removal in a future release. Its documentation points to +the recommended replacement — migrate at your earliest convenience. Deprecations and their removal +timelines are called out in the [changelog](https://github.com/databrickslabs/dqx/blob/v0.15.0/CHANGELOG.md). diff --git a/docs/dqx/src/css/custom.css b/docs/dqx/src/css/custom.css index 5db6450a0..0d5faad2e 100644 --- a/docs/dqx/src/css/custom.css +++ b/docs/dqx/src/css/custom.css @@ -793,3 +793,10 @@ button { .bv-fact-enter { animation: bvFactSlideUp 0.5s ease-out forwards; } + +/* ── Feature-status badge: brand red (matches the DQX logo, #ff3621) ── */ +.badge--beta { + background-color: #ff3621; + border-color: #ff3621; + color: #fff; +} diff --git a/docs/dqx/static/img/mcp-genie-auto-approve.png b/docs/dqx/static/img/mcp-genie-auto-approve.png new file mode 100644 index 000000000..3c072a174 Binary files /dev/null and b/docs/dqx/static/img/mcp-genie-auto-approve.png differ diff --git a/docs/dqx/static/img/mcp-genie-polling.png b/docs/dqx/static/img/mcp-genie-polling.png new file mode 100644 index 000000000..05cd9b092 Binary files /dev/null and b/docs/dqx/static/img/mcp-genie-polling.png differ diff --git a/docs/dqx/static/img/mcp-genie-settings.png b/docs/dqx/static/img/mcp-genie-settings.png new file mode 100644 index 000000000..60f2a4b06 Binary files /dev/null and b/docs/dqx/static/img/mcp-genie-settings.png differ diff --git a/docs/dqx/static/img/mcp-genie-steps-collapsed.png b/docs/dqx/static/img/mcp-genie-steps-collapsed.png new file mode 100644 index 000000000..95db1b45a Binary files /dev/null and b/docs/dqx/static/img/mcp-genie-steps-collapsed.png differ diff --git a/docs/dqx/static/img/mcp-genie-tools.png b/docs/dqx/static/img/mcp-genie-tools.png new file mode 100644 index 000000000..a0db53c59 Binary files /dev/null and b/docs/dqx/static/img/mcp-genie-tools.png differ diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 000000000..3c8ad6821 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.databricks/ +*.egg-info/ +uv.lock diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 000000000..e4d0b0be9 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,5 @@ +# DQX MCP Server + +An MCP (Model Context Protocol) server that exposes DQX data quality tools for AI agents. Runs as a Databricks App with on-behalf-of (OBO) authentication. + +For full documentation — architecture, deployment, configuration, and usage — see the [DQX MCP Server guide](https://databrickslabs.github.io/dqx/docs/guide/dqx_mcp_server). diff --git a/mcp-server/app.yaml b/mcp-server/app.yaml new file mode 100644 index 000000000..d3f5f6db8 --- /dev/null +++ b/mcp-server/app.yaml @@ -0,0 +1,8 @@ +command: ["python", "server/main.py"] +env: + - name: DQX_RUNNER_JOB_ID + valueFrom: dqx-runner-job + - name: DQX_CATALOG + valueFrom: dqx-catalog-secret + - name: DQX_TMP_SCHEMA + value: "tmp" diff --git a/mcp-server/databricks.yml b/mcp-server/databricks.yml new file mode 100644 index 000000000..519584ba4 --- /dev/null +++ b/mcp-server/databricks.yml @@ -0,0 +1,103 @@ +bundle: + name: "mcp-dqx" + # Deploy without Terraform. The direct engine avoids the `terraform init` provider download + # from registry.terraform.io (blocked on some networks/CI runners). Requires Databricks CLI + # 0.279.0+. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct + engine: direct + +variables: + users_group: + description: "Group name for all workspace users (e.g. 'account users' or 'users')" + default: "account users" + # Resource name prefix. Override (e.g. --var name_prefix=mcp-dqx-ci) to deploy an + # isolated copy alongside an existing deployment — used by the CI integration workflow. + name_prefix: + default: "mcp-dqx" + # Catalog for temp views. Empty => the setup job reads it from the secret scope (the + # default production behaviour). CI passes an explicit catalog it can create/drop in. + catalog_name: + default: "" + # Secret scope holding the catalog name (and any other config). Override per deployment. + config_secret_scope: + default: "dqx-config" +resources: + jobs: + dqx_runner: + name: "${var.name_prefix}-runner" + max_concurrent_runs: 10 + tasks: + - task_key: dqx_run + notebook_task: + notebook_path: "./notebooks/runner.py" + environment_key: default + environments: + - environment_key: default + spec: + environment_version: "2" + dependencies: + # Install the DQX library wheel built from THIS repo (see artifacts.dqx_library_wheel), + # not a published PyPI release, so the runner exercises in-repo (possibly unreleased) + # source and never drifts from what the PR actually changes. The [datacontract] extra's + # dependency is listed explicitly because pip extras can't be applied to a wheel path. + - ./.build/databricks_labs_dqx-*.whl + - datacontract-cli>=0.11.1,<1.0 + + # One-time setup job — grants USE CATALOG to users and app SP. + # Run after first deploy: databricks bundle run dqx_setup --profile + dqx_setup: + name: "${var.name_prefix}-setup" + tasks: + - task_key: setup + notebook_task: + notebook_path: "./notebooks/setup.py" + base_parameters: + app_name: "${var.name_prefix}" + catalog_name: "${var.catalog_name}" + secret_scope: "${var.config_secret_scope}" + users_group: ${var.users_group} + environment_key: default + environments: + - environment_key: default + spec: + environment_version: "2" + + apps: + mcp-dqx: + name: "${var.name_prefix}" + description: "DQX MCP Server - AI agent tools for data quality checks" + source_code_path: "." + + user_api_scopes: + - sql + - files + + resources: + - name: dqx-runner-job + job: + id: ${resources.jobs.dqx_runner.id} + permission: "CAN_MANAGE_RUN" + - name: dqx-catalog-secret + secret: + scope: ${var.config_secret_scope} + key: catalog_name + permission: "READ" + + permissions: + - level: CAN_USE + group_name: ${var.users_group} + +sync: + include: + - "notebooks/**" + - ".build/**" + +# Build the DQX library wheel from the repo root (../) so the runner job installs +# this branch's source rather than a published PyPI release. Mirrors the app/ bundle, +# which also builds the parent wheel via `uv build ../ --wheel`. +artifacts: + dqx_library_wheel: + build: uv build ../ --wheel --out-dir ./.build + +targets: + dev: + default: true diff --git a/mcp-server/notebooks/runner.py b/mcp-server/notebooks/runner.py new file mode 100644 index 000000000..3f5f249a3 --- /dev/null +++ b/mcp-server/notebooks/runner.py @@ -0,0 +1,462 @@ +# Databricks notebook source + +# COMMAND ---------- +# %pip install databricks-labs-dqx # installed via environment spec in databricks.yml + +# COMMAND ---------- + +import json +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("dqx-mcp-runner") + +# COMMAND ---------- + +# Read parameters from notebook widgets +dbutils.widgets.text("operation", "") +dbutils.widgets.text("params", "{}") + +operation = dbutils.widgets.get("operation") +params = json.loads(dbutils.widgets.get("params")) + +logger.info(f"Running operation: {operation}, params keys: {list(params.keys())}") + +# COMMAND ---------- + + +def profile_table(params: dict) -> dict: + """Profile a view and return summary stats + profiles.""" + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.profiler.profiler import DQProfiler + from databricks.labs.dqx.config import InputConfig + from dataclasses import asdict + + view_name = params["view_name"] + columns = params.get("columns") + options = params.get("options") + + ws = WorkspaceClient() + profiler = DQProfiler(workspace_client=ws, spark=spark) + input_config = InputConfig(location=view_name) + summary_stats, profiles = profiler.profile_table(input_config, columns=columns, options=options) + + profiles_dicts = [asdict(p) for p in profiles] + return { + "summary_stats": _make_json_safe(summary_stats), + "profiles": _make_json_safe(profiles_dicts), + } + + +# COMMAND ---------- + + +def generate_rules(params: dict) -> dict: + """Generate DQX rules from profiling output.""" + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.profiler.profiler import DQProfile + from databricks.labs.dqx.profiler.generator import DQGenerator + + profiles = params["profiles"] + criticality = params.get("criticality", "error") + + dq_profiles = [ + DQProfile( + name=p["name"], + column=p["column"], + description=p.get("description"), + parameters=p.get("parameters"), + filter=p.get("filter"), + ) + for p in profiles + ] + + ws = WorkspaceClient() + generator = DQGenerator(workspace_client=ws, spark=spark) + rules = generator.generate_dq_rules(dq_profiles, criticality=criticality) + + return { + "rules": _make_json_safe(rules), + "count": len(rules), + } + + +# COMMAND ---------- + + +def load_checks(params: dict) -> dict: + """Load DQX checks from a storage backend (table, UC volume, or workspace file). + + The backend is inferred from the location string by DQX's storage factory: + a 'catalog.schema.table' name -> Delta table, a '/Volumes/...' path -> UC volume + file, any other '/...' path -> workspace file. + """ + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.engine import DQEngine + from databricks.labs.dqx.checks_storage import ChecksStorageHandlerFactory + + location = params["location"] + run_config_name = params.get("run_config_name", "default") + + ws = WorkspaceClient() + engine = DQEngine(workspace_client=ws, spark=spark) + _handler, config = ChecksStorageHandlerFactory(ws, spark).create_for_location(location, run_config_name) + checks = engine.load_checks(config) + + return {"checks": _make_json_safe(checks), "count": len(checks), "location": location} + + +# COMMAND ---------- + + +def save_checks(params: dict) -> dict: + """Save DQX checks to a storage backend (table, UC volume, or workspace file). + + The backend is inferred from the location string. For table backends, *mode* + ('append' or 'overwrite') controls write semantics; it is ignored for file backends. + """ + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.engine import DQEngine + from databricks.labs.dqx.checks_storage import ChecksStorageHandlerFactory + + checks = params["checks"] + location = params["location"] + run_config_name = params.get("run_config_name", "default") + mode = params.get("mode", "append") + + ws = WorkspaceClient() + engine = DQEngine(workspace_client=ws, spark=spark) + _handler, config = ChecksStorageHandlerFactory(ws, spark).create_for_location(location, run_config_name) + # Table/Lakebase configs expose a write mode; file backends do not. + if hasattr(config, "mode"): + config.mode = mode + engine.save_checks(checks, config) + + # Grant the calling user access to the created table (table backends only — a 3-part name, + # not a /Volumes or /Workspace path) so they can use it outside the MCP. + grant_to = params.get("grant_to") + granted_to = None + if grant_to and "/" not in location and location.count(".") == 2: + if _grant_table_access(location, grant_to): + granted_to = grant_to + + return {"saved": True, "count": len(checks), "location": location, "access_granted_to": granted_to} + + +# COMMAND ---------- + + +def apply_checks_and_save_to_table(params: dict) -> dict: + """Apply checks to a view and persist results to output (and optional quarantine) tables.""" + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.engine import DQEngine + from databricks.labs.dqx.config import InputConfig, OutputConfig + + view_name = params["view_name"] + checks = params["checks"] + output_table = params["output_table"] + quarantine_table = params.get("quarantine_table") + mode = params.get("mode", "append") + + ws = WorkspaceClient() + engine = DQEngine(workspace_client=ws, spark=spark) + + input_config = InputConfig(location=view_name) + output_config = OutputConfig(location=output_table, mode=mode) + quarantine_config = OutputConfig(location=quarantine_table, mode=mode) if quarantine_table else None + + engine.apply_checks_by_metadata_and_save_in_table( + input_config=input_config, + output_config=output_config, + checks=checks, + quarantine_config=quarantine_config, + ) + + # Grant the calling user access to the output (and quarantine) tables so they can use them + # outside the MCP. Best-effort as the app SP (the owner of tables it just created). + grant_to = params.get("grant_to") + granted_tables = [] + if grant_to: + for tbl in (output_table, quarantine_table): + if tbl and _grant_table_access(tbl, grant_to): + granted_tables.append(tbl) + + result = {"output_table": output_table, "output_rows": spark.table(output_table).count()} + if quarantine_table: + result["quarantine_table"] = quarantine_table + result["quarantine_rows"] = spark.table(quarantine_table).count() + result["access_granted_to"] = grant_to if granted_tables else None + result["granted_tables"] = granted_tables + return result + + +# COMMAND ---------- + + +def generate_rules_from_contract(params: dict) -> dict: + """Generate DQX rules from an ODCS data contract file. + + Deterministic by default: schema/quality rules are derived from the contract. + Set process_text_rules=True to also process free-text expectations via the LLM + (requires the [llm] extra). + """ + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.profiler.generator import DQGenerator + + contract_file = params["contract_file"] + contract_format = params.get("contract_format", "odcs") + process_text_rules = params.get("process_text_rules", False) + default_criticality = params.get("default_criticality", "error") + + ws = WorkspaceClient() + generator = DQGenerator(workspace_client=ws, spark=spark) + rules = generator.generate_rules_from_contract( + contract_file=contract_file, + contract_format=contract_format, + process_text_rules=process_text_rules, + default_criticality=default_criticality, + ) + + return {"rules": _make_json_safe(rules), "count": len(rules)} + + +# COMMAND ---------- + + +def run_checks(params: dict) -> dict: + """Run DQX checks against a view.""" + from databricks.sdk import WorkspaceClient + from databricks.labs.dqx.engine import DQEngine + + view_name = params["view_name"] + checks = params["checks"] + sample_size = params.get("sample_size", 50) + + ws = WorkspaceClient() + engine = DQEngine(workspace_client=ws, spark=spark) + + df = spark.table(view_name) + valid_df, invalid_df = engine.apply_checks_by_metadata_and_split(df, checks) + + total_rows = df.count() + valid_rows = valid_df.count() + invalid_rows = invalid_df.count() + + error_sample_rows = invalid_df.limit(sample_size).collect() + error_sample = [_make_json_safe(row.asDict(recursive=True)) for row in error_sample_rows] + + rule_summary = _compute_rule_summary(invalid_df) + + return { + "total_rows": total_rows, + "valid_rows": valid_rows, + "invalid_rows": invalid_rows, + "error_sample": error_sample, + "rule_summary": rule_summary, + } + + +# COMMAND ---------- + + +def _make_json_safe(value): + """Recursively convert non-JSON-serializable values.""" + import datetime + from decimal import Decimal + + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (datetime.datetime, datetime.date)): + return value.isoformat() + if isinstance(value, dict): + return {k: _make_json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_make_json_safe(v) for v in value] + return value + + +# COMMAND ---------- + + +def _compute_rule_summary(invalid_df) -> list: + """Aggregate per-rule error/warning counts.""" + import pyspark.sql.functions as F + + summary = {} + for col_name in ("_errors", "_warnings"): + if col_name not in invalid_df.columns: + continue + exploded = invalid_df.select(F.explode(F.col(col_name)).alias("item")) + rows = exploded.groupBy("item.name").count().collect() + for row in rows: + rule_name = row["name"] or "unknown" + if rule_name not in summary: + summary[rule_name] = {"error_count": 0, "warning_count": 0} + if col_name == "_errors": + summary[rule_name]["error_count"] = row["count"] + else: + summary[rule_name]["warning_count"] = row["count"] + + return [{"rule_name": name, **counts} for name, counts in summary.items()] + + +# COMMAND ---------- + + +def validate_checks(params: dict) -> dict: + """Validate a list of DQX check definitions for correctness.""" + from databricks.labs.dqx.engine import DQEngine + + checks = params["checks"] + status = DQEngine.validate_checks(checks) + return { + "valid": not status.has_errors, + "errors": status.errors, + } + + +# COMMAND ---------- + + +def list_available_checks(params: dict) -> dict: + """List all built-in DQX check functions with signatures and descriptions.""" + import inspect + from databricks.labs.dqx.rule import CHECK_FUNC_REGISTRY + from databricks.labs.dqx.checks_resolver import resolve_check_function + + checks = [] + for name, func_type in sorted(CHECK_FUNC_REGISTRY.items()): + func = resolve_check_function(name, fail_on_missing=False) + if func is None: + continue + sig = inspect.signature(func) + func_params = [ + {"name": p.name, "type": str(p.annotation) if p.annotation != inspect.Parameter.empty else "Any"} + for p in sig.parameters.values() + ] + doc = inspect.getdoc(func) or "" + first_line = doc.split("\n")[0] if doc else "" + checks.append( + { + "name": name, + "type": func_type, + "signature": f"{name}{sig}", + "description": first_line, + "parameters": func_params, + } + ) + + return {"checks": checks, "count": len(checks)} + + +# COMMAND ---------- + + +def _grant_table_access(table_name, principal) -> bool: + """Best-effort grant of full access on a table to the calling user. Returns True on success. + + Grants ``ALL PRIVILEGES`` (read + modify the data) **and** ``MANAGE`` (edit, drop, and manage + grants on the table), so the user has full lifecycle control of MCP-created outputs outside + the MCP — without transferring **ownership**. Keeping the app service principal as owner means + a later overwrite run still works and does not depend on a grant the user could revoke. + (Exercising MANAGE to drop also requires USE CATALOG / USE SCHEMA on the parents, which the + user already has for tables they can reference.) Best-effort: logs and returns False on + failure (e.g. the table pre-existed and is owned by someone else, or there is no caller). + """ + import re + + if not principal or not table_name: + return False + # Validate before embedding in SQL — the principal comes from the OBO header and the table + # from the tool arguments. Reject anything that is not a plain principal / 3-part identifier. + if not re.match(r"^[A-Za-z0-9._%+\-@]+$", str(principal)): + logger.warning(f"Skipping grant: unexpected principal format {principal!r}") + return False + parts = str(table_name).split(".") + if len(parts) != 3 or not all(re.match(r"^[A-Za-z0-9_]+$", p) for p in parts): + logger.warning(f"Skipping grant: not a 3-part table name: {table_name!r}") + return False + fqn = ".".join(f"`{p}`" for p in parts) + try: + spark.sql(f"GRANT ALL PRIVILEGES, MANAGE ON TABLE {fqn} TO `{principal}`") + logger.info(f"Granted ALL PRIVILEGES + MANAGE on {table_name} to {principal}") + return True + except Exception: + logger.warning(f"Could not grant on {table_name} to {principal}", exc_info=True) + return False + + +# COMMAND ---------- + + +def _drop_view_safe(view_name) -> None: + """Drop the OBO-created temp view for this run. Best-effort: logs, never raises. + + Runs as the job's service principal, which owns the temp schema (see setup.py), + so it can drop views created by any user via their OBO token. Doing this here — + in the job that is guaranteed to run — means view cleanup no longer depends on the + user polling get_run_result or on which app replica handles the poll. + """ + import re + + if not view_name: + return + parts = str(view_name).split(".") + if len(parts) != 3 or not all(re.match(r"^[A-Za-z0-9_]+$", p) for p in parts): + logger.warning(f"Skipping drop of invalid view name: {view_name!r}") + return + safe_fqn = ".".join(f"`{p}`" for p in parts) + try: + spark.sql(f"DROP VIEW IF EXISTS {safe_fqn}") + logger.info(f"Dropped temp view {view_name}") + except Exception: + logger.warning(f"Failed to drop temp view {view_name}", exc_info=True) + + +# COMMAND ---------- + +# Operation dispatch +OPERATIONS = { + "profile_table": profile_table, + "generate_rules": generate_rules, + "generate_rules_from_contract": generate_rules_from_contract, + "load_checks": load_checks, + "save_checks": save_checks, + "run_checks": run_checks, + "apply_checks_and_save_to_table": apply_checks_and_save_to_table, + "validate_checks": validate_checks, + "list_available_checks": list_available_checks, +} + +# COMMAND ---------- + +try: + if operation not in OPERATIONS: + raise ValueError(f"Unknown operation: {operation}. Valid: {list(OPERATIONS.keys())}") + result = OPERATIONS[operation](params) + # Echo the source table name so the client knows which table the result is for. + # Done here (instead of re-attaching in the server) so the server needs no per-run state. + if isinstance(result, dict) and params.get("table_name") and "table_name" not in result: + result["table_name"] = params["table_name"] +except Exception as e: + logger.error(f"Operation '{operation}' failed: {e}", exc_info=True) + # Re-raise so the job's result_state becomes FAILED. Exiting normally with an + # {"error": ...} payload would make get_run_status report status="completed", + # and an agent driving the workflow would treat the failed op as success + # (e.g. feed the error dict into the next operation). + raise +finally: + # Always drop the run's temp view, on success or failure. + _drop_view_safe(params.get("view_name")) + +# Check output size before exit (5MB limit) +output_json = json.dumps(result) +if len(output_json) > 4_500_000: # 4.5MB safety margin + result = { + "error": "Output too large for notebook.exit() (>4.5MB). Try reducing sample_size.", + "truncated": True, + } + output_json = json.dumps(result) + +logger.info(f"Operation '{operation}' complete. Output size: {len(output_json)} bytes") +dbutils.notebook.exit(output_json) diff --git a/mcp-server/notebooks/setup.py b/mcp-server/notebooks/setup.py new file mode 100644 index 000000000..e610566e0 --- /dev/null +++ b/mcp-server/notebooks/setup.py @@ -0,0 +1,130 @@ +# Databricks notebook source + +# COMMAND ---------- + +""" +One-time setup notebook for the DQX MCP server. + +Run after `databricks bundle deploy` to create the temp view schema and +grant all required UC permissions. Safe to re-run (all statements are idempotent). + +Grants: + Catalog level: + - users → USE CATALOG + - app SP → USE CATALOG + Schema level (catalog.tmp): + - users → USE SCHEMA, CREATE TABLE (so OBO token can create views) + - app SP → USE SCHEMA, SELECT (so SP job can read through views) + - app SP → OWNER of the schema (so it can drop temp views created by + any user — only the owner / parent-schema owner can DROP in UC) + +Parameters: + - catalog_name: UC catalog for temp views (optional — reads from secret if not provided) + - app_name: Databricks App name (e.g. 'mcp-dqx') — used to look up the app SP + - users_group: Group name for all users (default: 'account users') + - secret_scope: Secret scope for catalog name (default: 'dqx-config') + - secret_key: Secret key for catalog name (default: 'catalog_name') +""" + +# COMMAND ---------- + +import logging +import re + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("dqx-mcp-setup") + +_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_ ]+$") + + +def _validate_identifier(value, label): + """Validate a SQL identifier to prevent injection via backtick breakout.""" + if not _SAFE_IDENTIFIER_RE.match(value): + raise ValueError( + f"Invalid {label}: '{value}'. Only alphanumeric characters, underscores, and spaces are allowed." + ) + return value + + +# COMMAND ---------- + +dbutils.widgets.text("catalog_name", "") +dbutils.widgets.text("app_name", "mcp-dqx") +dbutils.widgets.text("users_group", "account users") +dbutils.widgets.text("secret_scope", "dqx-config") +dbutils.widgets.text("secret_key", "catalog_name") + +catalog_name = dbutils.widgets.get("catalog_name") +app_name = dbutils.widgets.get("app_name") +users_group = dbutils.widgets.get("users_group") + +# Read catalog name from secret if not provided directly +if not catalog_name: + secret_scope = dbutils.widgets.get("secret_scope") + secret_key = dbutils.widgets.get("secret_key") + catalog_name = dbutils.secrets.get(scope=secret_scope, key=secret_key) + +if not catalog_name: + raise ValueError("catalog_name must be provided as a parameter or stored in the secret scope") + +_validate_identifier(catalog_name, "catalog_name") + +logger.info(f"Setting up DQX MCP: catalog={catalog_name}, app={app_name}, users_group={users_group}") + +# COMMAND ---------- + +# Look up the app's service principal +from databricks.sdk import WorkspaceClient + +ws = WorkspaceClient() +app = ws.apps.get(app_name) +sp_id = app.service_principal_id + +# Resolve SP application_id — UC GRANTs use application_id, not display_name +sp = ws.service_principals.get(sp_id) +sp_principal = sp.application_id +logger.info(f"App SP: display_name={sp.display_name}, application_id={sp_principal} (id={sp_id})") + +# COMMAND ---------- + +schema_name = "tmp" + +# Create schema if it doesn't exist +spark.sql(f"CREATE SCHEMA IF NOT EXISTS `{catalog_name}`.`{schema_name}`") +logger.info(f"Schema `{catalog_name}`.`{schema_name}` ready") + +# COMMAND ---------- + +# All grants — single source of truth +grants = [ + # Catalog-level + f"GRANT USE CATALOG ON CATALOG `{catalog_name}` TO `{users_group}`", + f"GRANT USE CATALOG ON CATALOG `{catalog_name}` TO `{sp_principal}`", + # Schema-level: users can create views via OBO token + f"GRANT USE SCHEMA ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{users_group}`", + f"GRANT CREATE TABLE ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{users_group}`", + # Schema-level: app SP can read through views + f"GRANT USE SCHEMA ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{sp_principal}`", + f"GRANT SELECT ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{sp_principal}`", +] + +for sql in grants: + logger.info(f"Executing: {sql}") + spark.sql(sql) + +# COMMAND ---------- + +# Transfer ownership of the temp schema to the app SP so it can manage the +# lifecycle of the temp views (drop them after a run / sweep stale ones). +# Temp views are created by the OBO user and are owned by that user; in Unity +# Catalog only the view owner, a principal with MANAGE, the parent-schema owner, +# or a metastore admin can DROP a view. Making the SP the schema owner lets the +# app clean up views created by any user. Data governance is unaffected: the +# views are definer's-rights, so the SP still reads source data *as the creating +# user*, never directly. Run last so the GRANTs above are issued while the +# setup principal still owns the schema. Idempotent. +alter_owner_sql = f"ALTER SCHEMA `{catalog_name}`.`{schema_name}` OWNER TO `{sp_principal}`" +logger.info(f"Executing: {alter_owner_sql}") +spark.sql(alter_owner_sql) + +logger.info("Setup complete — all grants applied and schema ownership assigned to the app SP.") diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 000000000..04c51aa64 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "mcp-dqx" +version = "0.1.0" +description = "DQX MCP Server - AI agent tools for data quality checks" +requires-python = ">=3.11" +dependencies = [ + # Runtime deps the server imports directly. Keep in sync with requirements.txt + # (the manifest the Databricks App installs). mcp/pydantic come in via fastmcp; + # databricks-labs-dqx is NOT a server dep — only the runner notebook uses it, and + # it is installed via the job environment spec in databricks.yml. + "fastmcp>=2.12.5", + "starlette", + "uvicorn>=0.34.2", + "databricks-sdk>=0.60.0", +] + +[project.scripts] +mcp-dqx = "server.main:main" + +# For local development, uncomment the following to use the local DQX source: +# [tool.uv.sources] +# databricks-labs-dqx = { path = "../", editable = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["server"] diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 000000000..5e119cb24 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=2.12.5 +starlette +uvicorn>=0.34.2 +databricks-sdk>=0.60.0 diff --git a/mcp-server/scripts/ci_deploy.sh b/mcp-server/scripts/ci_deploy.sh new file mode 100755 index 000000000..847203800 --- /dev/null +++ b/mcp-server/scripts/ci_deploy.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Deploy an isolated copy of the DQX MCP server (bundle + runner job + setup + app) for +# integration testing, and print the deployed app URL. Idempotent / safe to re-run. +# +# Auth comes from the standard Databricks CLI env (DATABRICKS_HOST + DATABRICKS_TOKEN, or a +# configured profile). Override the bundle's name/catalog/secret-scope via the env vars below +# so this never collides with a real deployment. +# +# Env: +# NAME_PREFIX resource name prefix (default: mcp-dqx-ci) +# DQX_MCP_TEST_CATALOG catalog the setup job may create/drop a temp schema in (required) +# CONFIG_SECRET_SCOPE secret scope holding catalog_name (default: dqx-config-ci) +# BUNDLE_TARGET bundle target (default: dev) +# DATABRICKS_PROFILE optional CLI profile (else relies on DATABRICKS_HOST/TOKEN) +# +# Emits: DQX_MCP_SERVER_URL= to stdout and, if set, to $GITHUB_OUTPUT. +set -euo pipefail + +NAME_PREFIX="${NAME_PREFIX:-mcp-dqx-ci}" +CONFIG_SECRET_SCOPE="${CONFIG_SECRET_SCOPE:-dqx-config-ci}" +BUNDLE_TARGET="${BUNDLE_TARGET:-dev}" +# Required config (CI: provided by the acceptance harness env + the deploy fixture). +: "${DATABRICKS_HOST:?DATABRICKS_HOST is not set (workspace URL)}" +: "${DATABRICKS_TOKEN:?DATABRICKS_TOKEN is not set (workspace auth)}" +: "${DQX_MCP_TEST_CATALOG:?DQX_MCP_TEST_CATALOG is not set (a catalog the deployer can create schemas in)}" +PROFILE_ARG=() +[ -n "${DATABRICKS_PROFILE:-}" ] && PROFILE_ARG=(--profile "$DATABRICKS_PROFILE") + +VARS=(--var "name_prefix=${NAME_PREFIX}" + --var "catalog_name=${DQX_MCP_TEST_CATALOG}" + --var "config_secret_scope=${CONFIG_SECRET_SCOPE}") + +cd "$(dirname "$0")/.." # mcp-server/ + +echo "::group::Configure catalog secret (${CONFIG_SECRET_SCOPE})" +databricks secrets create-scope "${CONFIG_SECRET_SCOPE}" "${PROFILE_ARG[@]}" 2>/dev/null || true +databricks secrets put-secret "${CONFIG_SECRET_SCOPE}" catalog_name \ + --string-value "${DQX_MCP_TEST_CATALOG}" "${PROFILE_ARG[@]}" +echo "::endgroup::" + +echo "::group::bundle deploy (${NAME_PREFIX}, target ${BUNDLE_TARGET})" +# The bundle uses `engine: direct` (no Terraform), so this does not download a provider. +databricks bundle deploy -t "${BUNDLE_TARGET}" "${VARS[@]}" "${PROFILE_ARG[@]}" +echo "::endgroup::" + +echo "::group::run setup job (UC grants + temp-schema ownership)" +databricks bundle run dqx_setup -t "${BUNDLE_TARGET}" "${VARS[@]}" "${PROFILE_ARG[@]}" +echo "::endgroup::" + +echo "::group::start + deploy app code" +databricks apps start "${NAME_PREFIX}" "${PROFILE_ARG[@]}" || true +# `apps deploy` requires the app COMPUTE to be ACTIVE. A brand-new app's app_status stays +# "UNAVAILABLE" until code is deployed, so we wait on compute_status (which `start` brings up), +# not app_status. +for _ in $(seq 1 60); do + state="$(databricks apps get "${NAME_PREFIX}" "${PROFILE_ARG[@]}" -o json \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("compute_status",{}).get("state",""))')" + [ "${state}" = "ACTIVE" ] && break + echo "waiting for app compute to become ACTIVE (state=${state:-unknown})..." + sleep 10 +done +# The app deploys from the bundle's synced files dir (workspace.file_path). +FILE_PATH="$(databricks bundle summary -t "${BUNDLE_TARGET}" "${VARS[@]}" "${PROFILE_ARG[@]}" -o json \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["workspace"]["file_path"])')" +databricks apps deploy "${NAME_PREFIX}" --source-code-path "${FILE_PATH}" "${PROFILE_ARG[@]}" +echo "::endgroup::" + +# Emit the app URL and the app's service principal (application id). The SP is the identity +# the runner job runs as, so tests that exercise the writing tools (save_checks / +# apply_checks_and_save_to_table) grant it write access on their throwaway schema. +read -r APP_URL APP_SP < <(databricks apps get "${NAME_PREFIX}" "${PROFILE_ARG[@]}" -o json \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["url"], d.get("service_principal_client_id",""))') +echo "DQX_MCP_SERVER_URL=${APP_URL}" +echo "DQX_MCP_APP_SERVICE_PRINCIPAL=${APP_SP}" +# Use if-blocks (not `[ ] && echo`): the latter returns non-zero when the var is unset (local +# runs), which would make this script exit 1 on an otherwise successful deploy. +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "server_url=${APP_URL}" >> "$GITHUB_OUTPUT"; fi +if [ -n "${GITHUB_ENV:-}" ]; then echo "DQX_MCP_SERVER_URL=${APP_URL}" >> "$GITHUB_ENV"; fi diff --git a/mcp-server/scripts/ci_destroy.sh b/mcp-server/scripts/ci_destroy.sh new file mode 100755 index 000000000..9c9839d9b --- /dev/null +++ b/mcp-server/scripts/ci_destroy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Tear down the isolated CI deployment created by ci_deploy.sh. Best-effort; never fails the job. +set -uo pipefail + +NAME_PREFIX="${NAME_PREFIX:-mcp-dqx-ci}" +CONFIG_SECRET_SCOPE="${CONFIG_SECRET_SCOPE:-dqx-config-ci}" +BUNDLE_TARGET="${BUNDLE_TARGET:-dev}" +PROFILE_ARG=() +[ -n "${DATABRICKS_PROFILE:-}" ] && PROFILE_ARG=(--profile "$DATABRICKS_PROFILE") + +VARS=(--var "name_prefix=${NAME_PREFIX}" + --var "catalog_name=${DQX_MCP_TEST_CATALOG:-}" + --var "config_secret_scope=${CONFIG_SECRET_SCOPE}") + +cd "$(dirname "$0")/.." # mcp-server/ + +echo "::group::bundle destroy (${NAME_PREFIX})" +databricks bundle destroy -t "${BUNDLE_TARGET}" --auto-approve "${VARS[@]}" "${PROFILE_ARG[@]}" || true +echo "::endgroup::" + +# bundle destroy removes the app; drop the CI secret scope too (ignore if absent). +databricks secrets delete-scope "${CONFIG_SECRET_SCOPE}" "${PROFILE_ARG[@]}" 2>/dev/null || true diff --git a/mcp-server/server/__init__.py b/mcp-server/server/__init__.py new file mode 100644 index 000000000..57ef74e7d --- /dev/null +++ b/mcp-server/server/__init__.py @@ -0,0 +1 @@ +# DQX MCP Server package diff --git a/mcp-server/server/app.py b/mcp-server/server/app.py new file mode 100644 index 000000000..5b07c5964 --- /dev/null +++ b/mcp-server/server/app.py @@ -0,0 +1,64 @@ +""" +FastMCP application configuration for the DQX MCP server. + +- Use stateless_http=True + json_response=True for Genie Code compatibility +- Add CORSMiddleware for OPTIONS preflight requests from workspace origin +- Use pure ASGI middleware (not BaseHTTPMiddleware) to avoid streaming timeouts +- OBO: extract X-Forwarded-Access-Token, store in contextvars, create per-request client lazily +""" + +import logging + +from fastmcp import FastMCP +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from .tools import load_tools +from .utils import OBOAuthMiddleware, configure_logging + +# Ensure logging is configured even when this module is the entry point (e.g. a direct +# `uvicorn server.app:combined_app` in dev). Idempotent — a no-op once main.py has run. +configure_logging() + +logger = logging.getLogger(__name__) + +mcp_server = FastMCP(name="mcp-dqx") + +load_tools(mcp_server) + +# Get the ASGI app — stateless_http + json_response for Genie Code compatibility +combined_app = mcp_server.http_app( + stateless_http=True, + json_response=True, +) + + +# Add health check route +async def health_check(request: Request) -> JSONResponse: + return JSONResponse({"message": "DQX MCP Server is running", "status": "healthy"}) + + +combined_app.routes.insert(0, Route("/", health_check)) + +# Add OBO middleware (pure ASGI — no BaseHTTPMiddleware to avoid streaming timeouts) +combined_app.add_middleware(OBOAuthMiddleware) + +# Allowed cross-origin callers (browser-based Genie Code). Region-agnostic: the +# subdomain wildcard matches any Databricks workspace host regardless of deploy region. +# Server-to-server callers (e.g. Claude) are not subject to CORS and are unaffected. +CORS_ALLOWED_ORIGIN_REGEX = ( + r"https://.*\.(databricks\.com|databricksapps\.com|azuredatabricks\.net|gcp\.databricks\.com)" +) + +# Add CORS middleware — Genie Code sends OPTIONS preflight from workspace origin. +# Use allow_origin_regex (not allow_origins=["*"]) so that allow_credentials=True stays +# spec-compliant: Starlette reflects the matched origin instead of the forbidden "*". +combined_app.add_middleware( + CORSMiddleware, + allow_origin_regex=CORS_ALLOWED_ORIGIN_REGEX, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) diff --git a/mcp-server/server/main.py b/mcp-server/server/main.py new file mode 100644 index 000000000..0c8508994 --- /dev/null +++ b/mcp-server/server/main.py @@ -0,0 +1,42 @@ +""" +Main entry point for the DQX MCP server application. +""" + +import logging +import os +import sys + +import uvicorn + +logger = logging.getLogger(__name__) + + +def main(): + # Add parent dir to path so 'server' package is importable + parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent not in sys.path: + sys.path.insert(0, parent) + + # Configure logging once 'server' is importable (the script is run as 'server/main.py', + # so the package is only on sys.path after the insert above). + from server.utils import configure_logging + + configure_logging() + + logger.debug(f"sys.path[0]={parent}") + logger.debug(f"cwd={os.getcwd()}") + logger.debug(f"files in parent: {os.listdir(parent)}") + logger.debug(f"files in server: {os.listdir(os.path.join(parent, 'server'))}") + + from server.app import combined_app + + port = int(os.getenv("DATABRICKS_APP_PORT", "8000")) + uvicorn.run( + combined_app, + host="0.0.0.0", + port=port, + ) + + +if __name__ == "__main__": + main() diff --git a/mcp-server/server/tools.py b/mcp-server/server/tools.py new file mode 100644 index 000000000..67844dbf6 --- /dev/null +++ b/mcp-server/server/tools.py @@ -0,0 +1,517 @@ +import logging +import os + +from server import utils + +logger = logging.getLogger(__name__) + + +def _get_tmp_view_config() -> tuple[str, str]: + """Get catalog and schema for temporary views from environment.""" + catalog = os.environ.get("DQX_CATALOG", "") + schema = os.environ.get("DQX_TMP_SCHEMA", "tmp") + if not catalog: + raise RuntimeError("DQX_CATALOG not set. Deploy the bundle first.") + return catalog, schema + + +def load_tools(mcp_server): + """Register all DQX MCP tools with the server. + + All DQX operations run in a notebook job — the app has no pyspark dependency. + Tools that access tables create a temporary view via the user's OBO token + (enforcing UC governance), then the SP job reads through the view. + + Long-running tools (profile_table, run_checks, generate_rules, etc.) return + a run_id immediately. Use get_run_result to poll for results. + """ + + @mcp_server.tool + def get_table_schema(table_name: str): + """Retrieve the schema and basic metadata for a Databricks table. + + Args: + table_name: Fully qualified table name (e.g. 'catalog.schema.table'). + + Returns a dict with: + - 'table_name': the input table name + - 'columns': list of {name, type, comment} for each column + """ + logger.info(f"Getting schema for table: {utils.sanitize_for_log(table_name)}") + obo_ws = utils.get_obo_client() + warehouse_id = utils.get_warehouse_id(obo_ws) + + safe_table = utils.validate_and_quote_table_name(table_name) + rows = utils.execute_sql( + obo_ws, + f"DESCRIBE TABLE {safe_table}", + warehouse_id=warehouse_id, + ) + + # DESCRIBE TABLE lists the real columns first. For a partitioned table it then + # emits a blank separator row followed by a "# Partition Information" section that + # re-lists each partition column WITHOUT a "#" prefix. Stop at the first blank or + # "#"-prefixed row so partition columns aren't duplicated. + columns = [] + for row in rows: + col_name = row.get("col_name") + if not col_name or col_name.startswith("#"): + break + columns.append({"name": col_name, "type": row["data_type"], "comment": row.get("comment", "")}) + + return {"table_name": table_name, "columns": columns} + + @mcp_server.tool + def profile_table( + table_name: str, + columns: list[str] | None = None, + options: dict | None = None, + ): + """Profile a Databricks table to get summary statistics and auto-generated data quality profiles. + + This tool submits a profiling job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + table_name: Fully qualified table name (e.g. 'catalog.schema.table'). + columns: Optional list of column names to profile. Profiles all columns if omitted. + options: Optional profiling options. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Profiling table: {utils.sanitize_for_log(table_name)}") + obo_ws = utils.get_obo_client() + warehouse_id = utils.get_warehouse_id(obo_ws) + catalog, schema = _get_tmp_view_config() + + view_fqn = utils.create_temp_view(obo_ws, table_name, catalog, schema, warehouse_id) + + run_id = utils.submit_job_async( + "profile_table", + {"view_name": view_fqn, "table_name": table_name, "columns": columns, "options": options}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Profiling job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def generate_rules(profiles: list[dict], criticality: str = "error"): + """Generate DQX data quality check definitions from profiling output. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + profiles: List of profile dicts from profile_table result. + criticality: Default criticality: 'error' or 'warn' (default 'error'). + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Generating rules from {len(profiles)} profiles, criticality={criticality}") + + run_id = utils.submit_job_async( + "generate_rules", + {"profiles": profiles, "criticality": criticality}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Rule generation job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def generate_rules_from_contract( + contract_file: str, + contract_format: str = "odcs", + default_criticality: str = "error", + process_text_rules: bool = False, + ): + """Generate DQX check definitions from a data contract (ODCS). + + Derives checks from the contract's schema and quality expectations — a + deterministic alternative to profiling when a data contract already exists. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + contract_file: Path to the contract file (workspace, UC volume, or local). + contract_format: Contract format (default 'odcs'). + default_criticality: Default criticality for generated rules: 'error' or 'warn'. + process_text_rules: If True, also derive rules from free-text expectations via + the LLM (requires the [llm] extra). Default False keeps generation deterministic. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Generating rules from contract: {utils.sanitize_for_log(contract_file)}") + + run_id = utils.submit_job_async( + "generate_rules_from_contract", + { + "contract_file": contract_file, + "contract_format": contract_format, + "default_criticality": default_criticality, + "process_text_rules": process_text_rules, + }, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Contract rule generation job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def load_checks(location: str, run_config_name: str = "default"): + """Load saved DQX checks from a storage backend. + + Use this to retrieve previously saved checks. The backend is inferred from + the location: a 'catalog.schema.table' name is a Delta table, a '/Volumes/...' + path is a UC volume file, any other '/...' path is a workspace file. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + location: Table name or file path where the checks are stored. + run_config_name: Run configuration name to filter by (table backends only). + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Loading checks from: {utils.sanitize_for_log(location)}") + + run_id = utils.submit_job_async( + "load_checks", + {"location": location, "run_config_name": run_config_name}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Load checks job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def save_checks( + checks: list[dict], + location: str, + run_config_name: str = "default", + mode: str = "append", + ): + """Save DQX checks to a storage backend for later reuse. + + The backend is inferred from the location: a 'catalog.schema.table' name is a + Delta table, a '/Volumes/...' path is a UC volume file, any other '/...' path is + a workspace file. Writes run as the app service principal, so it must have write + access to the target location. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + checks: List of DQX check definitions (metadata format) to save. + location: Table name or file path to save the checks to. + run_config_name: Run configuration name to tag the checks with (table backends only). + mode: Write mode for table backends, 'append' or 'overwrite' (ignored for files). + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Saving {len(checks)} checks to: {utils.sanitize_for_log(location)}") + + run_id = utils.submit_job_async( + "save_checks", + { + "checks": checks, + "location": location, + "run_config_name": run_config_name, + "mode": mode, + # Grant the calling user access to the table this creates (table backends only), + # so they can use it outside the MCP. The runner does this best-effort as the SP. + "grant_to": utils.get_user_email(), + }, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Save checks job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def validate_checks(checks: list[dict]): + """Validate a list of DQX data quality check definitions for correctness. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Validating {len(checks)} check(s)") + + run_id = utils.submit_job_async( + "validate_checks", + {"checks": checks}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Validation job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def run_checks(table_name: str, checks: list[dict], sample_size: int = 50): + """Execute DQX data quality checks against a Databricks table. + + This tool submits a check job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + table_name: Fully qualified table name (e.g. 'catalog.schema.table'). + checks: List of DQX check definitions (metadata format). + sample_size: Max number of invalid rows to include in the sample (default 50). + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Running {len(checks)} checks on table: {utils.sanitize_for_log(table_name)}") + obo_ws = utils.get_obo_client() + warehouse_id = utils.get_warehouse_id(obo_ws) + catalog, schema = _get_tmp_view_config() + + view_fqn = utils.create_temp_view(obo_ws, table_name, catalog, schema, warehouse_id) + + run_id = utils.submit_job_async( + "run_checks", + {"view_name": view_fqn, "table_name": table_name, "checks": checks, "sample_size": sample_size}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Check job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def apply_checks_and_save_to_table( + table_name: str, + checks: list[dict], + output_table: str, + quarantine_table: str | None = None, + mode: str = "append", + ): + """Apply DQX checks to a table and persist the results to output table(s). + + Unlike run_checks (which returns a sample), this operationalizes the checks: + results are written to Delta tables. If quarantine_table is given, valid rows go + to output_table and invalid rows to quarantine_table; otherwise all rows (with + _errors/_warnings columns) go to output_table. Writes run as the app service + principal, which must have write access to the target schema. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Args: + table_name: Fully qualified source table name (e.g. 'catalog.schema.table'). + checks: List of DQX check definitions (metadata format). + output_table: Fully qualified table to write results to. + quarantine_table: Optional fully qualified table for invalid rows. + mode: Write mode, 'append' or 'overwrite' (default 'append'). + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info( + f"Applying {len(checks)} checks on {utils.sanitize_for_log(table_name)} " + f"-> {utils.sanitize_for_log(output_table)}" + ) + obo_ws = utils.get_obo_client() + warehouse_id = utils.get_warehouse_id(obo_ws) + catalog, schema = _get_tmp_view_config() + + view_fqn = utils.create_temp_view(obo_ws, table_name, catalog, schema, warehouse_id) + + run_id = utils.submit_job_async( + "apply_checks_and_save_to_table", + { + "view_name": view_fqn, + "table_name": table_name, + "checks": checks, + "output_table": output_table, + "quarantine_table": quarantine_table, + "mode": mode, + # Grant the calling user access to the output/quarantine tables this creates, + # so they can use them outside the MCP. The runner does this best-effort as the SP. + "grant_to": utils.get_user_email(), + }, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Apply-and-save job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def get_run_result(run_id: int): + """Check the status of a submitted job and retrieve results when complete. + + Call this after profile_table, generate_rules, validate_checks, or run_checks. + If the job is still running, call this tool again after a short wait. + + Args: + run_id: The run_id returned by a prior tool call. + + Returns a dict with: + - 'status': 'running', 'completed', or 'failed' + - 'run_id': the run ID + - 'result': the operation result (only when status is 'completed') + - 'error': error message (only when status is 'failed') + """ + logger.info(f"Checking run result: run_id={run_id}") + return utils.get_run_status(run_id) + + @mcp_server.tool + def list_available_checks(): + """List all built-in DQX check functions available for use in rules. + + This tool submits a job and returns a run_id immediately. + Call get_run_result with the run_id to check status and retrieve results. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info("Listing available check functions") + + run_id = utils.submit_job_async( + "list_available_checks", + {}, + ) + + return { + "status": "submitted", + "run_id": run_id, + "message": "Job submitted. Call get_run_result with this run_id to get results.", + } + + @mcp_server.tool + def get_workflow(): + """Get the recommended workflow for running DQX data quality checks on a table. + + Call this tool FIRST to understand the correct sequence of tool calls. + + IMPORTANT: Most tools return a run_id immediately instead of blocking. + After calling a tool, use get_run_result(run_id) to poll for results. + If status is 'running', wait a moment and call get_run_result again. + """ + return { + "description": "DQX data quality workflow for profiling a table, generating rules, and running checks.", + "async_pattern": "Most tools submit a job and return a run_id immediately. Call get_run_result(run_id) to poll for results. If status is 'running', wait and call get_run_result again.", + "steps": [ + { + "step": 1, + "tool": "get_table_schema", + "purpose": "Understand the table structure before profiling.", + "required_input": {"table_name": "Fully qualified table name (e.g. 'catalog.schema.table')"}, + "output": "Column names, types, and comments.", + "async": False, + }, + { + "step": 2, + "tool": "profile_table", + "purpose": "Profile the table data to discover patterns.", + "required_input": {"table_name": "Same table name from step 1"}, + "output": "Returns run_id. Call get_run_result(run_id) to get summary statistics and profiles.", + "async": True, + }, + { + "step": 3, + "tool": "generate_rules", + "purpose": "Convert profiling output into DQX check rule definitions.", + "required_input": {"profiles": "The 'profiles' list from step 2 result"}, + "output": "Returns run_id. Call get_run_result(run_id) to get the rules.", + "async": True, + }, + { + "step": 4, + "tool": "validate_checks", + "purpose": "Validate that the rule definitions are correct before running them.", + "required_input": {"checks": "The 'rules' list from step 3 result"}, + "output": "Returns run_id. Call get_run_result(run_id) to get validation status.", + "async": True, + "optional": True, + }, + { + "step": 5, + "tool": "run_checks", + "purpose": "Execute the rules against the table and get data quality results.", + "required_input": { + "table_name": "Same table name from step 1", + "checks": "The validated 'rules' from step 3", + }, + "output": "Returns run_id. Call get_run_result(run_id) to get row counts, per-rule summary, and failing rows.", + "async": True, + }, + ], + "helper_tools": [ + {"tool": "get_run_result", "purpose": "Poll for results of any async tool call. Pass the run_id."}, + {"tool": "list_available_checks", "purpose": "Discover all 68+ built-in check functions (async)."}, + { + "tool": "generate_rules_from_contract", + "purpose": ( + "Alternative to steps 2-3: if a data contract (ODCS) exists, generate checks " + "directly from it. Output 'rules' feeds into validate_checks and run_checks." + ), + "async": True, + }, + { + "tool": "save_checks", + "purpose": ( + "Persist a validated rule set to a table, UC volume, or workspace file so DQX " + "pipelines (or a later session) can reuse it." + ), + "async": True, + }, + { + "tool": "load_checks", + "purpose": "Retrieve a previously saved rule set by location; output feeds run_checks.", + "async": True, + }, + { + "tool": "apply_checks_and_save_to_table", + "purpose": ( + "Operationalized alternative to run_checks (step 5): write valid/quarantine rows " + "to Delta tables instead of returning a sample." + ), + "async": True, + }, + ], + "notes": [ + "All data tools require a real Unity Catalog table name.", + "You can modify the generated rules between steps 3 and 5.", + "Re-run validate_checks after any manual edits to rules.", + "Rules can come from profiling (steps 2-3) or a data contract " + "(generate_rules_from_contract); both produce the same format.", + "To operationalize: save_checks to persist the rule set, and " + "apply_checks_and_save_to_table to write results to Delta tables.", + ], + } diff --git a/mcp-server/server/utils.py b/mcp-server/server/utils.py new file mode 100644 index 000000000..2611d01c6 --- /dev/null +++ b/mcp-server/server/utils.py @@ -0,0 +1,581 @@ +""" +Utility functions for the DQX MCP server. + +Key patterns: +- Pure ASGI middleware for OBO (not BaseHTTPMiddleware — avoids streaming timeouts) +- Extracts user identity from Databricks Apps proxy headers +- User OBO token creates temp views (UC governance) and runs direct SQL +- SP submits notebook jobs that read through definer's-rights views +- Async job pattern: submit returns run_id, get_run_result fetches output +- auth_type="pat" to avoid conflict with auto-injected SP env vars +""" + +import contextvars +import json +import logging +import os +import re +import sys +import time +import uuid +from typing import Any + +from starlette.types import ASGIApp, Receive, Scope, Send + +logger = logging.getLogger(__name__) + + +def sanitize_for_log(value: object) -> str: + """Strip CR/LF from a value before logging to prevent log injection (CWE-117). + + User-supplied values (table names, view names) may contain newlines or carriage + returns that could forge log entries or corrupt log pipelines. Replace them with + spaces before interpolating into a log message. + + Args: + value: Any value to be logged. + + Returns: + String form of *value* with newlines and carriage returns replaced by spaces. + """ + return str(value).replace("\n", " ").replace("\r", " ") + + +# ── Logging configuration ──────────────────────────────────────────── + +# Per-request correlation id (set by OBOAuthMiddleware), so log lines from a single +# request — across the tool handler, SQL, and job submission — can be traced together +# in the Databricks Apps log stream, which is the only place these logs surface. +_request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None) + +# Third-party loggers that are noisy at INFO and would otherwise bury the server's own logs. +_NOISY_LOGGERS = ("databricks.sdk", "httpx", "httpcore", "urllib3", "py4j") + +_LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] [req=%(request_id)s user=%(user)s] %(message)s" + +_logging_configured = False + + +class RequestContextFilter(logging.Filter): + """Inject the per-request correlation id and calling user into every log record. + + Attached to the root handler so *all* records (including third-party ones) carry + ``request_id`` and ``user`` fields, defaulting to ``"-"`` outside a request. The + user email is sanitized (CWE-117) since it originates from a request header. + """ + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = _request_id_var.get(None) or "-" + email = _user_email_var.get(None) + record.user = sanitize_for_log(email) if email else "-" + return True + + +def configure_logging() -> None: + """Configure root logging for the MCP server. Idempotent and entry-point agnostic. + + Safe to call from any entry point (``server/main.py``, a direct ``uvicorn`` invocation, + or tests). Emits to stdout (where Databricks Apps collects logs), honors the + ``DQX_MCP_LOG_LEVEL`` env var (default ``INFO``), tags every line with the request id and + calling user, and quiets noisy third-party loggers so the server's own logs stand out. + """ + global _logging_configured + if _logging_configured: + return + + level_name = os.environ.get("DQX_MCP_LOG_LEVEL", "INFO").upper() + level = logging.getLevelName(level_name) + if not isinstance(level, int): + level = logging.INFO + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter(_LOG_FORMAT)) + handler.addFilter(RequestContextFilter()) + + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level) + + for name in _NOISY_LOGGERS: + logging.getLogger(name).setLevel(logging.WARNING) + + _logging_configured = True + + +# ── OBO Auth via contextvars ────────────────────────────────────────── + +# Store user identity per-request from Databricks Apps proxy headers +_user_token_var: contextvars.ContextVar[tuple[str, str] | None] = contextvars.ContextVar("user_token", default=None) +_user_email_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("user_email", default=None) + +# Service principal client singleton (fallback when no OBO token) +_sp_client = None + + +class OBOAuthMiddleware: + """Pure ASGI middleware for on-behalf-of authentication. + + Extracts user identity from Databricks Apps proxy headers: + - X-Forwarded-Access-Token: user's OBO token (used to call run_now() as user) + - X-Forwarded-Email: user's email (grant principal + log context) + + Also establishes a per-request correlation id (honoring an inbound ``X-Request-Id``, + otherwise generated) and logs one line per request with status and duration, so a + request can be traced end-to-end in the Databricks Apps log stream. All request + context is reset on the way out so it never leaks across requests on a reused worker. + + Using pure ASGI (not BaseHTTPMiddleware) is critical — BaseHTTPMiddleware + buffers response bodies which causes MCP streaming timeouts. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + + headers = dict(scope.get("headers", [])) + user_token = headers.get(b"x-forwarded-access-token", b"").decode() or None + user_email = headers.get(b"x-forwarded-email", b"").decode() or None + # Correlate logs across the request; honor an upstream trace id if the proxy set one. + incoming_id = headers.get(b"x-request-id", b"").decode().strip() + request_id = (incoming_id or uuid.uuid4().hex)[:32] + + host = os.environ.get("DATABRICKS_HOST", "") + token_tok = _user_token_var.set((host, user_token) if user_token else None) + email_tok = _user_email_var.set(user_email) + request_tok = _request_id_var.set(request_id) + + status_holder = {"code": 0} + + async def send_wrapper(message: dict) -> None: + if message.get("type") == "http.response.start": + status_holder["code"] = message.get("status", 0) + await send(message) + + method = scope.get("method", "-") + path = sanitize_for_log(scope.get("path", "-")) + # Health probes hit "/" constantly; log them at DEBUG so they don't drown real traffic. + log_at = logger.debug if path == "/" else logger.info + started = time.monotonic() + try: + await self.app(scope, receive, send_wrapper) + except Exception: + elapsed_ms = int((time.monotonic() - started) * 1000) + logger.exception(f"request error: {method} {path} after {elapsed_ms}ms") + raise + else: + elapsed_ms = int((time.monotonic() - started) * 1000) + log_at(f"request: {method} {path} status={status_holder['code']} {elapsed_ms}ms") + finally: + _user_token_var.reset(token_tok) + _user_email_var.reset(email_tok) + _request_id_var.reset(request_tok) + + +def get_obo_client(): + """Get a WorkspaceClient authenticated with the user's OBO token. + + Used for operations that must run as the user (SQL queries, view creation) + to enforce Unity Catalog governance. + + Raises: + RuntimeError: If no OBO token is available in the current request context. + """ + from databricks.sdk import WorkspaceClient + from databricks.sdk.config import Config + + token_info = _user_token_var.get(None) + if token_info is None: + raise RuntimeError( + "No OBO token available. This operation requires a user context (X-Forwarded-Access-Token header)." + ) + + host, token = token_info + cfg = Config(host=host, token=token, auth_type="pat") + return WorkspaceClient(config=cfg) + + +def get_user_email() -> str | None: + """The calling user's email (from the X-Forwarded-Email OBO header), or None. + + Used as the principal to grant on MCP-created tables so the user can read/manage the outputs + outside the MCP. None when there is no user context (e.g. a non-OBO/service-principal call), + in which case the runner simply skips the grant. + """ + return _user_email_var.get(None) + + +def _get_sp_client(): + """Get the app's service principal WorkspaceClient. + + The SP is used for job submission. UC governance is enforced before this + call via temporary views created with the user's OBO token. + """ + from databricks.sdk import WorkspaceClient + + global _sp_client + if _sp_client is None: + _sp_client = WorkspaceClient() + return _sp_client + + +# ── SQL helpers (OBO) ──────────────────────────────────────────────── + + +def get_warehouse_id(ws: Any) -> str: + """Auto-discover a SQL warehouse the user has access to. + + Picks the first running or available warehouse. The user's OBO token + has 'sql' scope so it can list warehouses they have access to. + + Args: + ws: WorkspaceClient (OBO or SP). + + Returns: + Warehouse ID string. + + Raises: + RuntimeError: If no warehouses are available. + """ + warehouses = list(ws.warehouses.list()) + if not warehouses: + raise RuntimeError("No SQL warehouses available. Check workspace permissions.") + + # Prefer a running warehouse to avoid startup wait + for wh in warehouses: + if wh.state and wh.state.value == "RUNNING": + logger.debug(f"Using running warehouse: {wh.name} ({wh.id})") + return wh.id + + # Fall back to first available + wh = warehouses[0] + logger.debug(f"Using warehouse: {wh.name} ({wh.id})") + return wh.id + + +def execute_sql(ws: Any, query: str, warehouse_id: str) -> list[dict[str, Any]]: + """Execute a SQL query using the Databricks SQL Statement API. + + Args: + ws: WorkspaceClient (OBO or SP). + query: SQL query string. + warehouse_id: SQL warehouse ID to execute against. + + Returns: + List of row dicts. + + Raises: + RuntimeError: If the query fails. + """ + result = ws.statement_execution.execute_statement( + statement=query, + warehouse_id=warehouse_id, + wait_timeout="30s", + ) + + state = str(result.status.state.value if hasattr(result.status.state, "value") else result.status.state) + if state != "SUCCEEDED": + error_msg = getattr(result.status.error, "message", str(result.status.error)) if result.status.error else state + raise RuntimeError(f"SQL query failed: {error_msg}") + + columns = [col.name for col in result.manifest.schema.columns] + rows = [] + if result.result and result.result.data_array: + for row_data in result.result.data_array: + rows.append(dict(zip(columns, row_data))) + return rows + + +_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def _validate_sql_identifier(name: str, label: str) -> str: + """Validate and backtick-quote a SQL identifier to prevent injection. + + Args: + name: Raw identifier (catalog, schema, or table name part). + label: Human-readable label for error messages (e.g. 'catalog'). + + Returns: + Backtick-quoted identifier safe for SQL interpolation. + + Raises: + ValueError: If *name* contains characters outside ``[A-Za-z0-9_]``. + """ + if not _SAFE_IDENTIFIER_RE.match(name): + raise ValueError(f"Invalid {label}: '{name}'. Only alphanumeric characters and underscores are allowed.") + return f"`{name}`" + + +def validate_and_quote_table_name(table_name: str) -> str: + """Validate a fully qualified table name and return a backtick-quoted version. + + Args: + table_name: Fully qualified table name (catalog.schema.table). + + Returns: + Backtick-quoted table name safe for SQL interpolation. + + Raises: + ValueError: If table_name is not fully qualified or contains unsafe characters. + """ + parts = table_name.split(".") + if len(parts) != 3: + raise ValueError(f"Table name '{table_name}' must be fully qualified (catalog.schema.table)") + + quoted = [_validate_sql_identifier(p, label) for p, label in zip(parts, ("catalog", "schema", "table"))] + return ".".join(quoted) + + +def create_temp_view( + ws: Any, + table_name: str, + catalog: str, + schema: str, + warehouse_id: str, +) -> str: + """Create a temporary view over a table using the user's OBO credentials. + + The view creation enforces UC governance — if the user can't read the source + table, the CREATE VIEW fails. The view uses definer's rights (UC default), + so the SP can read through it using the creator's permissions. + + Args: + ws: WorkspaceClient (OBO — user's identity). + table_name: Fully qualified source table (catalog.schema.table). + catalog: Catalog for the temp view. + schema: Schema for the temp view. + warehouse_id: SQL warehouse ID. + + Returns: + Fully qualified view name (catalog.schema.v_{uuid}). + + Raises: + ValueError: If table_name is not fully qualified or contains unsafe characters. + RuntimeError: If view creation fails (e.g., user lacks SELECT on source table). + """ + import time + import uuid + + safe_source = validate_and_quote_table_name(table_name) + view_catalog = _validate_sql_identifier(catalog, "view catalog") + view_schema = _validate_sql_identifier(schema, "view schema") + + # Encode the creation epoch in the name (v__) so the sweeper can drop + # stale views by age. The UUID keeps it unique; the whole name stays within the + # identifier-safety charset. + view_basename = f"v_{int(time.time())}_{uuid.uuid4().hex[:12]}" + view_name = _validate_sql_identifier(view_basename, "view name") + view_fqn = f"{catalog}.{schema}.{view_basename}" + + logger.info(f"Creating temp view {sanitize_for_log(view_fqn)} over {sanitize_for_log(table_name)}") + execute_sql( + ws, + f"CREATE VIEW {view_catalog}.{view_schema}.{view_name} AS SELECT * FROM {safe_source}", + warehouse_id=warehouse_id, + ) + return view_fqn + + +def drop_view(ws: Any, view_fqn: str, warehouse_id: str) -> None: + """Drop a temporary view. Logs errors but does not raise. + + Args: + ws: WorkspaceClient (SP or OBO). + view_fqn: Fully qualified view name to drop. + warehouse_id: SQL warehouse ID. + """ + parts = view_fqn.split(".") + if len(parts) != 3: + logger.warning(f"Invalid view name '{sanitize_for_log(view_fqn)}', skipping drop") + return + + quoted_parts = [] + for part in parts: + if not _SAFE_IDENTIFIER_RE.match(part): + logger.warning(f"Invalid identifier in view name '{sanitize_for_log(view_fqn)}', skipping drop") + return + quoted_parts.append(f"`{part}`") + + safe_fqn = ".".join(quoted_parts) + logger.info(f"Dropping temp view {sanitize_for_log(view_fqn)}") + try: + execute_sql(ws, f"DROP VIEW IF EXISTS {safe_fqn}", warehouse_id=warehouse_id) + except Exception: + logger.warning(f"Failed to drop temp view {sanitize_for_log(view_fqn)}", exc_info=True) + + +# ── Temp-view sweeper (backstop cleanup) ───────────────────────────── + +# View names are v__. The runner job drops its own view in a finally, +# so this sweeper only catches orphans: views whose job never started or was killed +# before cleanup. It runs as the SP, which owns the temp schema (see setup.py). +_VIEW_NAME_RE = re.compile(r"^v_(\d+)_[0-9a-f]+$") +_VIEW_TTL_SECONDS = 3600 # drop views older than 1 hour +_SWEEP_INTERVAL_SECONDS = 600 # sweep at most once per 10 minutes per replica +_last_sweep_at = 0.0 + + +def sweep_stale_views( + ws: Any, catalog: str, schema: str, warehouse_id: str, ttl_seconds: int = _VIEW_TTL_SECONDS +) -> int: + """Drop temp views in *catalog.schema* older than *ttl_seconds*. Best-effort. + + Identifies age from the v__ name. Returns the number of views dropped. + Never raises — logs and moves on so cleanup can't break request handling. + """ + import time + + safe_catalog = _validate_sql_identifier(catalog, "catalog") + safe_schema = _validate_sql_identifier(schema, "schema") + now = int(time.time()) + dropped = 0 + try: + rows = execute_sql(ws, f"SHOW VIEWS IN {safe_catalog}.{safe_schema}", warehouse_id=warehouse_id) + except Exception: + logger.warning(f"View sweep: failed to list views in {sanitize_for_log(f'{catalog}.{schema}')}", exc_info=True) + return 0 + + for row in rows: + view_name = row.get("viewName") or row.get("tableName") or "" + match = _VIEW_NAME_RE.match(view_name) + if not match: + continue + age = now - int(match.group(1)) + if age > ttl_seconds: + drop_view(ws, f"{catalog}.{schema}.{view_name}", warehouse_id=warehouse_id) + dropped += 1 + if dropped: + logger.info(f"View sweep: dropped {dropped} stale view(s) in {sanitize_for_log(f'{catalog}.{schema}')}") + return dropped + + +def _maybe_sweep_stale_views() -> None: + """Run the stale-view sweep at most once per interval. Never raises.""" + import time + + global _last_sweep_at + now = time.time() + if now - _last_sweep_at < _SWEEP_INTERVAL_SECONDS: + return + _last_sweep_at = now + + catalog = os.environ.get("DQX_CATALOG", "") + schema = os.environ.get("DQX_TMP_SCHEMA", "tmp") + if not catalog: + return + try: + ws = _get_sp_client() + warehouse_id = get_warehouse_id(ws) + sweep_stale_views(ws, catalog, schema, warehouse_id) + except Exception: + logger.warning("View sweep: skipped due to error", exc_info=True) + + +# ── Jobs API — async submit + poll ─────────────────────────────────── + + +def _get_runner_job_id() -> int: + """Get the pre-deployed runner job ID from environment.""" + job_id = os.environ.get("DQX_RUNNER_JOB_ID") + if not job_id: + raise RuntimeError("DQX_RUNNER_JOB_ID not set. Deploy the bundle first: databricks bundle deploy") + return int(job_id) + + +def submit_job_async(operation: str, params: dict[str, Any]) -> int: + """Submit a DQX operation and return the run_id immediately (non-blocking). + + Stateless by design: the runner job drops its own temp view (params['view_name']) + in a finally and echoes params['table_name'] into its result, so no per-run state + is kept in the server process. This means a restart or a poll landing on a + different app replica does not leak views or lose context. + + Args: + operation: The DQX operation name (e.g. 'profile_table', 'run_checks'). + params: Dict of parameters to pass to the notebook as JSON. For table-backed + operations, include 'view_name' (dropped by the runner) and 'table_name' + (echoed back in the result). + + Returns: + The Databricks job run_id. + """ + # Opportunistically reap orphaned temp views (throttled). Backstop for views whose + # job never started or was killed before its own cleanup ran. + _maybe_sweep_stale_views() + + ws = _get_sp_client() + job_id = _get_runner_job_id() + + logger.info(f"Submitting async job {job_id}: operation={operation}") + + wait = ws.jobs.run_now( + job_id=job_id, + notebook_params={ + "operation": operation, + "params": json.dumps(params), + }, + ) + + run_id = wait.run_id + logger.info(f"Job submitted: run_id={run_id}") + return run_id + + +def get_run_status(run_id: int) -> dict[str, Any]: + """Check the status of a submitted job run with a single, non-blocking poll. + + Performs one status check and returns immediately as 'completed' (with result), + 'failed', or 'running'. When 'running', the caller polls again — the client drives + the cadence. We deliberately do NOT wait/sleep internally: holding the HTTP + connection (and an anyio worker thread, since the tools are sync) open for the whole + job would risk client/proxy timeouts and saturate the thread pool under concurrent + polls. + + Args: + run_id: The Databricks job run_id from a prior submit call. + + Returns: + Dict with 'status' ('running', 'completed', 'failed') and optionally 'result'. + """ + ws = _get_sp_client() + + run = ws.jobs.get_run(run_id) + life_cycle = run.state.life_cycle_state.value if run.state and run.state.life_cycle_state else "UNKNOWN" + if life_cycle in ("PENDING", "RUNNING", "QUEUED", "BLOCKED"): + return {"status": "running", "run_id": run_id, "message": "Job is still running. Call get_run_result again."} + + # No local cleanup here: the runner job drops its own temp view, and any orphans are + # reaped by the sweeper. This keeps get_run_status stateless and replica-independent. + + # Check for failure + result_state = run.state.result_state.value if run.state and run.state.result_state else "UNKNOWN" + if result_state != "SUCCESS": + error_msg = run.state.state_message if run.state else "Unknown error" + run_url = run.run_page_url or "" + return { + "status": "failed", + "run_id": run_id, + "error": f"Job failed: {error_msg}. Debug at: {run_url}", + } + + # Extract notebook output + task_run_id = run.tasks[0].run_id if run.tasks else run.run_id + output = ws.jobs.get_run_output(task_run_id) + + if output.notebook_output and output.notebook_output.result: + result = json.loads(output.notebook_output.result) + # table_name is echoed by the runner into the result, so nothing to re-attach here. + return {"status": "completed", "run_id": run_id, "result": result} + + error_msg = output.error or "No output from notebook" + run_url = run.run_page_url or "" + return { + "status": "failed", + "run_id": run_id, + "error": f"No output: {error_msg}. Debug at: {run_url}", + } diff --git a/mcp-server/tests/__init__.py b/mcp-server/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mcp-server/tests/test_app_http.py b/mcp-server/tests/test_app_http.py new file mode 100644 index 000000000..a02634277 --- /dev/null +++ b/mcp-server/tests/test_app_http.py @@ -0,0 +1,102 @@ +"""HTTP/transport integration tests driving the real ASGI app (combined_app). + +These exercise the production stack the way Genie Code / an MCP client hits it over +Streamable HTTP: the health route, the MCP endpoint (initialize, tools/list), and OBO +token propagation through OBOAuthMiddleware -> contextvar -> get_obo_client. The workspace +boundary (warehouse/SQL) is faked so no live workspace is needed. CORS is covered separately +in test_cors.py. + +A single module-scoped TestClient runs the app lifespan once (the FastMCP Streamable HTTP +session manager can only be started once per app instance). +""" + +import json +from unittest.mock import patch + +import pytest +from starlette.testclient import TestClient + +from server.app import combined_app + +_MCP_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + + +@pytest.fixture(scope="module") +def client(): + with TestClient(combined_app) as c: + yield c + + +def _rpc(method: str, params: dict) -> dict: + return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + + +def _parse(resp) -> dict: + text = resp.text + if "data:" in text[:32]: + text = text.split("data:", 1)[1].strip() + return json.loads(text) + + +class TestHealth: + def test_health_endpoint(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert resp.json()["status"] == "healthy" + + +class TestMcpOverHttp: + def test_initialize(self, client): + resp = client.post( + "/mcp", + headers=_MCP_HEADERS, + json=_rpc( + "initialize", + {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}, + ), + ) + body = _parse(resp) + assert body["result"]["protocolVersion"] + assert body["result"]["capabilities"]["tools"] is not None + + def test_tools_list(self, client): + resp = client.post("/mcp", headers=_MCP_HEADERS, json=_rpc("tools/list", {})) + body = _parse(resp) + names = {t["name"] for t in body["result"]["tools"]} + assert {"get_table_schema", "run_checks", "save_checks", "generate_rules_from_contract"} <= names + + +class TestOboPropagation: + """The forwarded user token must flow: header -> OBOAuthMiddleware -> contextvar -> get_obo_client.""" + + _describe = [ + {"col_name": "id", "data_type": "int", "comment": ""}, + {"col_name": "name", "data_type": "string", "comment": ""}, + ] + + def test_obo_token_present_allows_governed_call(self, client): + with ( + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.execute_sql", return_value=self._describe), + patch.dict("os.environ", {"DATABRICKS_HOST": "https://host.example.com"}), + ): + resp = client.post( + "/mcp", + headers={**_MCP_HEADERS, "X-Forwarded-Access-Token": "user-obo-token"}, + json=_rpc("tools/call", {"name": "get_table_schema", "arguments": {"table_name": "c.s.t"}}), + ) + result = _parse(resp)["result"] + assert not result.get("isError"), f"unexpected error: {result}" + data = result.get("structuredContent") or json.loads(result["content"][0]["text"]) + assert data["table_name"] == "c.s.t" + assert len(data["columns"]) == 2 + + def test_missing_obo_token_is_rejected(self, client): + with patch.dict("os.environ", {"DATABRICKS_HOST": "https://host.example.com"}): + resp = client.post( + "/mcp", + headers=_MCP_HEADERS, # no X-Forwarded-Access-Token + json=_rpc("tools/call", {"name": "get_table_schema", "arguments": {"table_name": "c.s.t"}}), + ) + # The tool raises ("No OBO token available..."); surfaced as an MCP error result. + assert "obo" in resp.text.lower() diff --git a/mcp-server/tests/test_cors.py b/mcp-server/tests/test_cors.py new file mode 100644 index 000000000..fda0aded5 --- /dev/null +++ b/mcp-server/tests/test_cors.py @@ -0,0 +1,95 @@ +"""Unit tests for the MCP server CORS policy. + +These tests drive the real Starlette CORSMiddleware using the same +CORS_ALLOWED_ORIGIN_REGEX wired into the production app, asserting observable +CORS behaviour (which origins are reflected) rather than the regex string. +""" + +import pytest +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from server.app import CORS_ALLOWED_ORIGIN_REGEX + + +def _make_client() -> TestClient: + """Minimal app wrapped with the production CORS config.""" + + async def ok(request): # noqa: ANN001, ANN202 - test stub + return PlainTextResponse("ok") + + app = Starlette(routes=[Route("/", ok, methods=["GET", "POST"])]) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=CORS_ALLOWED_ORIGIN_REGEX, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + ) + return TestClient(app) + + +ALLOWED_ORIGINS = [ + "https://adb-7405605361901583.3.azuredatabricks.net", # Azure (the deployed workspace) + "https://myworkspace.cloud.databricks.com", # AWS + "https://1234567890.gcp.databricks.com", # GCP + "https://mcp-dqx-1234.eastus.databricksapps.com", # Databricks Apps host +] + +REJECTED_ORIGINS = [ + "https://evil.example.com", + "https://databricks.com.evil.example.com", # suffix-spoof attempt + "http://myworkspace.cloud.databricks.com", # http (not https) + "https://notdatabricks.net", +] + + +class TestCorsPreflight: + @pytest.mark.parametrize("origin", ALLOWED_ORIGINS) + def test_databricks_origin_reflected_with_credentials(self, origin: str): + client = _make_client() + resp = client.options( + "/", + headers={ + "origin": origin, + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + ) + assert resp.status_code == 200 + # Spec-compliant with credentials: the exact origin is reflected, never "*". + assert resp.headers["access-control-allow-origin"] == origin + assert resp.headers["access-control-allow-credentials"] == "true" + + @pytest.mark.parametrize("origin", REJECTED_ORIGINS) + def test_non_databricks_origin_rejected(self, origin: str): + client = _make_client() + resp = client.options( + "/", + headers={ + "origin": origin, + "access-control-request-method": "POST", + }, + ) + # Starlette returns 400 for a disallowed preflight and omits the allow-origin header. + assert resp.status_code == 400 + assert "access-control-allow-origin" not in resp.headers + + +class TestCorsSimpleRequest: + def test_allowed_origin_reflected_on_actual_request(self): + client = _make_client() + resp = client.get("/", headers={"origin": ALLOWED_ORIGINS[0]}) + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == ALLOWED_ORIGINS[0] + assert resp.headers["access-control-allow-credentials"] == "true" + + def test_disallowed_origin_not_reflected_on_actual_request(self): + client = _make_client() + resp = client.get("/", headers={"origin": "https://evil.example.com"}) + # The request itself still succeeds, but the browser gets no allow-origin header. + assert resp.status_code == 200 + assert "access-control-allow-origin" not in resp.headers diff --git a/mcp-server/tests/test_mcp_protocol.py b/mcp-server/tests/test_mcp_protocol.py new file mode 100644 index 000000000..4e4b1be6f --- /dev/null +++ b/mcp-server/tests/test_mcp_protocol.py @@ -0,0 +1,110 @@ +"""Protocol-level unit tests driving the server through FastMCP's in-memory Client. + +Unlike test_tools.py (which calls the registered handler functions directly), these run +the REAL MCP protocol in-process: capability negotiation, tool registration/schemas, and +call_tool dispatch — exactly what a client (Claude, Genie Code, Cursor) exercises — with the +workspace boundary (utils.*) faked so they stay deterministic and need no workspace. +""" + +from unittest.mock import patch + +import pytest + +from fastmcp import Client +from server.app import mcp_server + + +# anyio is used as the async test runner (matches the existing async tests in test_utils.py). +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +_ENV = {"DQX_RUNNER_JOB_ID": "42", "DQX_CATALOG": "dqx_mcp", "DQX_TMP_SCHEMA": "tmp"} + +EXPECTED_TOOLS = { + "get_table_schema", + "profile_table", + "generate_rules", + "generate_rules_from_contract", + "load_checks", + "save_checks", + "validate_checks", + "run_checks", + "apply_checks_and_save_to_table", + "get_run_result", + "list_available_checks", + "get_workflow", +} + + +class TestToolDiscovery: + @pytest.mark.anyio + async def test_list_tools_exposes_all_tools(self): + async with Client(mcp_server) as client: + tools = await client.list_tools() + names = {t.name for t in tools} + assert EXPECTED_TOOLS <= names, f"missing: {EXPECTED_TOOLS - names}" + + @pytest.mark.anyio + async def test_tools_have_description_and_schema(self): + async with Client(mcp_server) as client: + tools = await client.list_tools() + for t in tools: + assert t.description, f"{t.name} has no description" + assert t.inputSchema and t.inputSchema.get("type") == "object", f"{t.name} has no input schema" + + +class TestToolInvocation: + @pytest.mark.anyio + async def test_get_workflow_returns_steps(self): + async with Client(mcp_server) as client: + res = await client.call_tool("get_workflow", {}) + assert "steps" in res.data + assert len(res.data["steps"]) == 5 + + @pytest.mark.anyio + async def test_generate_rules_submits_job(self): + with patch("server.tools.utils.submit_job_async", return_value=7) as mock_submit: + async with Client(mcp_server) as client: + res = await client.call_tool("generate_rules", {"profiles": [{"name": "p1"}], "criticality": "warn"}) + mock_submit.assert_called_once_with("generate_rules", {"profiles": [{"name": "p1"}], "criticality": "warn"}) + assert res.data["status"] == "submitted" + assert res.data["run_id"] == 7 + + @pytest.mark.anyio + async def test_profile_table_creates_view_and_submits(self): + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.create_temp_view", return_value="dqx_mcp.tmp.v_abc"), + patch("server.tools.utils.submit_job_async", return_value=999) as mock_submit, + patch.dict("os.environ", _ENV), + ): + async with Client(mcp_server) as client: + res = await client.call_tool("profile_table", {"table_name": "catalog.schema.table"}) + op, params = mock_submit.call_args[0] + assert op == "profile_table" + assert params["view_name"] == "dqx_mcp.tmp.v_abc" + assert params["table_name"] == "catalog.schema.table" + assert res.data["run_id"] == 999 + + @pytest.mark.anyio + async def test_save_checks_submits_job(self): + with patch("server.tools.utils.submit_job_async", return_value=23) as mock_submit: + async with Client(mcp_server) as client: + res = await client.call_tool( + "save_checks", {"checks": [{"check": "foo"}], "location": "c.s.checks", "mode": "overwrite"} + ) + mock_submit.assert_called_once_with( + "save_checks", + { + "checks": [{"check": "foo"}], + "location": "c.s.checks", + "run_config_name": "default", + "mode": "overwrite", + # no OBO user context in the in-memory client, so there is nobody to grant to + "grant_to": None, + }, + ) + assert res.data["run_id"] == 23 diff --git a/mcp-server/tests/test_tools.py b/mcp-server/tests/test_tools.py new file mode 100644 index 000000000..bf7481a0c --- /dev/null +++ b/mcp-server/tests/test_tools.py @@ -0,0 +1,291 @@ +from unittest.mock import MagicMock, patch + + +_ENV = { + "DQX_RUNNER_JOB_ID": "42", + "DQX_CATALOG": "dqx_mcp", + "DQX_TMP_SCHEMA": "tmp", +} + + +def _register_tools(): + """Helper to register tools and return them as a dict.""" + from server.tools import load_tools + + mock_mcp = MagicMock() + registered_tools = {} + + def capture_tool(func): + registered_tools[func.__name__] = func + return func + + mock_mcp.tool = capture_tool + load_tools(mock_mcp) + return registered_tools + + +class TestGetTableSchema: + """Test that get_table_schema uses direct SQL via OBO, not a job.""" + + def test_calls_execute_sql_with_obo_client(self): + tools = _register_tools() + + describe_rows = [ + {"col_name": "id", "data_type": "int", "comment": ""}, + {"col_name": "name", "data_type": "string", "comment": ""}, + ] + + with ( + patch("server.tools.utils.get_obo_client") as mock_obo, + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.execute_sql", return_value=describe_rows) as mock_sql, + patch.dict("os.environ", _ENV), + ): + result = tools["get_table_schema"]("catalog.schema.table") + + mock_obo.assert_called_once() + mock_sql.assert_called_once() + assert result["table_name"] == "catalog.schema.table" + assert len(result["columns"]) == 2 + assert result["columns"][0] == {"name": "id", "type": "int", "comment": ""} + + def test_filters_metadata_rows(self): + tools = _register_tools() + + describe_rows = [ + {"col_name": "id", "data_type": "int", "comment": ""}, + {"col_name": "# Partition Information", "data_type": "", "comment": ""}, + {"col_name": "# col_name", "data_type": "data_type", "comment": "comment"}, + ] + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.execute_sql", return_value=describe_rows), + patch.dict("os.environ", _ENV), + ): + result = tools["get_table_schema"]("catalog.schema.table") + + assert len(result["columns"]) == 1 + assert result["columns"][0]["name"] == "id" + + def test_partition_columns_not_duplicated(self): + tools = _register_tools() + + # DESCRIBE TABLE on a partitioned table re-lists each partition column + # (here "dt") under "# Partition Information" without a "#" prefix. + describe_rows = [ + {"col_name": "id", "data_type": "int", "comment": ""}, + {"col_name": "name", "data_type": "string", "comment": ""}, + {"col_name": "dt", "data_type": "date", "comment": ""}, + {"col_name": "", "data_type": "", "comment": ""}, + {"col_name": "# Partition Information", "data_type": "", "comment": ""}, + {"col_name": "# col_name", "data_type": "data_type", "comment": "comment"}, + {"col_name": "dt", "data_type": "date", "comment": ""}, + ] + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.execute_sql", return_value=describe_rows), + patch.dict("os.environ", _ENV), + ): + result = tools["get_table_schema"]("catalog.schema.table") + + assert [c["name"] for c in result["columns"]] == ["id", "name", "dt"] + + +class TestProfileTable: + """Test that profile_table creates a view and submits a job, returning a run_id.""" + + def test_creates_view_and_submits_job(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client") as mock_obo, + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.create_temp_view", return_value="dqx_mcp.tmp.v_abc") as mock_create, + patch("server.tools.utils.submit_job_async", return_value=999) as mock_submit, + patch.dict("os.environ", _ENV), + ): + result = tools["profile_table"]("catalog.schema.table") + + mock_obo.assert_called_once() + mock_create.assert_called_once() + assert mock_submit.call_args[0][0] == "profile_table" + job_params = mock_submit.call_args[0][1] + assert job_params["view_name"] == "dqx_mcp.tmp.v_abc" + # table_name travels in params (echoed back by the runner), not in server-side state. + assert job_params["table_name"] == "catalog.schema.table" + assert "metadata" not in mock_submit.call_args.kwargs + assert result["status"] == "submitted" + assert result["run_id"] == 999 + + +class TestRunChecks: + """Test that run_checks creates a view and submits a job, returning a run_id.""" + + def test_creates_view_and_submits_job(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.create_temp_view", return_value="dqx_mcp.tmp.v_abc") as mock_create, + patch("server.tools.utils.submit_job_async", return_value=999) as mock_submit, + patch.dict("os.environ", _ENV), + ): + result = tools["run_checks"]("catalog.schema.table", [{"check": "foo"}]) + + mock_create.assert_called_once() + assert mock_submit.call_args[0][0] == "run_checks" + job_params = mock_submit.call_args[0][1] + assert job_params["view_name"] == "dqx_mcp.tmp.v_abc" + assert job_params["table_name"] == "catalog.schema.table" + assert job_params["checks"] == [{"check": "foo"}] + assert job_params["sample_size"] == 50 + assert "metadata" not in mock_submit.call_args.kwargs + assert result["status"] == "submitted" + assert result["run_id"] == 999 + + +class TestGetRunResult: + """Test that get_run_result delegates to utils.get_run_status.""" + + def test_delegates_to_get_run_status(self): + tools = _register_tools() + + with patch( + "server.tools.utils.get_run_status", + return_value={"status": "completed", "run_id": 5, "result": {}}, + ) as mock_status: + result = tools["get_run_result"](5) + + mock_status.assert_called_once_with(5) + assert result["status"] == "completed" + + +class TestJobOnlyTools: + """Test tools that just submit a job (no view) and return a run_id.""" + + def test_generate_rules(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=7) as mock_submit: + result = tools["generate_rules"]([{"name": "p1"}], "warn") + + mock_submit.assert_called_once_with("generate_rules", {"profiles": [{"name": "p1"}], "criticality": "warn"}) + assert result["status"] == "submitted" + assert result["run_id"] == 7 + + def test_validate_checks(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=8) as mock_submit: + result = tools["validate_checks"]([{"check": "foo"}]) + + mock_submit.assert_called_once_with("validate_checks", {"checks": [{"check": "foo"}]}) + assert result["status"] == "submitted" + assert result["run_id"] == 8 + + def test_list_available_checks(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=9) as mock_submit: + result = tools["list_available_checks"]() + + mock_submit.assert_called_once_with("list_available_checks", {}) + assert result["status"] == "submitted" + assert result["run_id"] == 9 + + def test_get_workflow_returns_steps(self): + tools = _register_tools() + result = tools["get_workflow"]() + assert "steps" in result + assert len(result["steps"]) == 5 + + def test_generate_rules_from_contract(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=21) as mock_submit: + result = tools["generate_rules_from_contract"]("/Volumes/c/s/v/contract.yml") + + mock_submit.assert_called_once_with( + "generate_rules_from_contract", + { + "contract_file": "/Volumes/c/s/v/contract.yml", + "contract_format": "odcs", + "default_criticality": "error", + "process_text_rules": False, + }, + ) + assert result["run_id"] == 21 + + def test_load_checks(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=22) as mock_submit: + result = tools["load_checks"]("catalog.schema.checks") + + mock_submit.assert_called_once_with( + "load_checks", {"location": "catalog.schema.checks", "run_config_name": "default"} + ) + assert result["run_id"] == 22 + + def test_save_checks(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.submit_job_async", return_value=23) as mock_submit, + patch("server.tools.utils.get_user_email", return_value="user@example.com"), + ): + result = tools["save_checks"]([{"check": "foo"}], "/Workspace/checks.yml", mode="overwrite") + + mock_submit.assert_called_once_with( + "save_checks", + { + "checks": [{"check": "foo"}], + "location": "/Workspace/checks.yml", + "run_config_name": "default", + "mode": "overwrite", + "grant_to": "user@example.com", # the calling user, for the runner's grant-on-write + }, + ) + assert result["run_id"] == 23 + + +class TestApplyChecksAndSaveToTable: + """Test that apply_checks_and_save_to_table creates a view and submits a job.""" + + def test_creates_view_and_submits_job(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch("server.tools.utils.create_temp_view", return_value="dqx_mcp.tmp.v_abc") as mock_create, + patch("server.tools.utils.submit_job_async", return_value=24) as mock_submit, + patch("server.tools.utils.get_user_email", return_value="user@example.com"), + patch.dict("os.environ", _ENV), + ): + result = tools["apply_checks_and_save_to_table"]( + "catalog.schema.orders", + [{"check": "foo"}], + "catalog.schema.orders_out", + quarantine_table="catalog.schema.orders_quarantine", + ) + + mock_create.assert_called_once() + assert mock_submit.call_args[0][0] == "apply_checks_and_save_to_table" + job_params = mock_submit.call_args[0][1] + assert job_params["view_name"] == "dqx_mcp.tmp.v_abc" + assert job_params["checks"] == [{"check": "foo"}] + assert job_params["output_table"] == "catalog.schema.orders_out" + assert job_params["quarantine_table"] == "catalog.schema.orders_quarantine" + assert job_params["mode"] == "append" + # table_name travels in params; the runner drops the temp view itself (no server metadata). + assert job_params["table_name"] == "catalog.schema.orders" + # the calling user is forwarded so the runner can grant them access to the outputs + assert job_params["grant_to"] == "user@example.com" + assert "metadata" not in mock_submit.call_args.kwargs + assert result["run_id"] == 24 diff --git a/mcp-server/tests/test_utils.py b/mcp-server/tests/test_utils.py new file mode 100644 index 000000000..f3c1847cf --- /dev/null +++ b/mcp-server/tests/test_utils.py @@ -0,0 +1,560 @@ +import json +import logging +from unittest.mock import MagicMock, create_autospec, patch + +import pytest + +from databricks.sdk import WorkspaceClient +from databricks.sdk.service.jobs import ( + Run, + RunOutput, + NotebookOutput, + RunState, + RunLifeCycleState, + RunResultState, +) + + +_JOB_ID_ENV = {"DQX_RUNNER_JOB_ID": "42"} + + +def _make_terminated_run(result_state: RunResultState, run_id: int = 123) -> MagicMock: + """Build a mock Run that has finished, with a single task.""" + run = MagicMock(spec=Run) + run.run_id = run_id + run.run_page_url = f"https://workspace.databricks.com/jobs/{run_id}" + run.state = MagicMock(spec=RunState) + run.state.life_cycle_state = RunLifeCycleState.TERMINATED + run.state.result_state = result_state + run.state.state_message = "boom" + task_run = MagicMock() + task_run.run_id = 456 + run.tasks = [task_run] + return run + + +class TestGetOboClient: + def test_returns_obo_client_when_token_present(self): + from server.utils import get_obo_client, _user_token_var + + token = _user_token_var.set(("https://host.databricks.com", "user-token-123")) + try: + client = get_obo_client() + assert client is not None + finally: + _user_token_var.reset(token) + + def test_raises_when_no_token(self): + from server.utils import get_obo_client, _user_token_var + + token = _user_token_var.set(None) + try: + with pytest.raises(RuntimeError, match="No OBO token"): + get_obo_client() + finally: + _user_token_var.reset(token) + + +class TestGetUserEmail: + def test_returns_email_when_present(self): + from server.utils import get_user_email, _user_email_var + + token = _user_email_var.set("alice@example.com") + try: + assert get_user_email() == "alice@example.com" + finally: + _user_email_var.reset(token) + + def test_returns_none_without_user_context(self): + from server.utils import get_user_email, _user_email_var + + token = _user_email_var.set(None) + try: + assert get_user_email() is None + finally: + _user_email_var.reset(token) + + +class TestRequestContextFilter: + def _record(self) -> logging.LogRecord: + return logging.LogRecord("n", logging.INFO, "f", 1, "msg", None, None) + + def test_injects_defaults_outside_request(self): + from server.utils import RequestContextFilter + + record = self._record() + assert RequestContextFilter().filter(record) is True + assert record.request_id == "-" + assert record.user == "-" + + def test_injects_request_id_and_user(self): + from server.utils import RequestContextFilter, _request_id_var, _user_email_var + + rid = _request_id_var.set("abc123") + em = _user_email_var.set("alice@example.com") + try: + record = self._record() + RequestContextFilter().filter(record) + assert record.request_id == "abc123" + assert record.user == "alice@example.com" + finally: + _request_id_var.reset(rid) + _user_email_var.reset(em) + + def test_sanitizes_newlines_in_user(self): + from server.utils import RequestContextFilter, _user_email_var + + em = _user_email_var.set("a@b.com\nINJECTED log line") + try: + record = self._record() + RequestContextFilter().filter(record) + assert "\n" not in record.user and "\r" not in record.user + finally: + _user_email_var.reset(em) + + +class TestConfigureLogging: + def test_idempotent_and_attaches_filter(self): + import server.utils as u + + saved_handlers = logging.getLogger().handlers[:] + saved_flag = u._logging_configured + try: + u._logging_configured = False + u.configure_logging() + root = logging.getLogger() + assert any(isinstance(f, u.RequestContextFilter) for h in root.handlers for f in h.filters) + count = len(root.handlers) + u.configure_logging() # second call must be a no-op + assert len(root.handlers) == count + finally: + logging.getLogger().handlers[:] = saved_handlers + u._logging_configured = saved_flag + + +class TestExecuteSql: + def test_executes_query_and_returns_rows(self): + from server.utils import execute_sql + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_col_a = MagicMock() + mock_col_a.name = "col_name" + mock_col_b = MagicMock() + mock_col_b.name = "data_type" + mock_result.manifest.schema.columns = [mock_col_a, mock_col_b] + mock_result.result.data_array = [["id", "INT"], ["name", "STRING"]] + ws.statement_execution.execute_statement.return_value = mock_result + + rows = execute_sql(ws, "DESCRIBE TABLE catalog.schema.table", warehouse_id="wh123") + assert rows == [{"col_name": "id", "data_type": "INT"}, {"col_name": "name", "data_type": "STRING"}] + + def test_raises_on_failed_query(self): + from server.utils import execute_sql + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "FAILED" + mock_result.status.error = MagicMock() + mock_result.status.error.message = "Table not found" + ws.statement_execution.execute_statement.return_value = mock_result + + with pytest.raises(RuntimeError, match="SQL query failed"): + execute_sql(ws, "DESCRIBE TABLE bad.table", warehouse_id="wh123") + + def test_returns_empty_list_when_no_results(self): + from server.utils import execute_sql + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_result.manifest.schema.columns = [] + mock_result.result = None + ws.statement_execution.execute_statement.return_value = mock_result + + rows = execute_sql(ws, "DROP VIEW IF EXISTS foo", warehouse_id="wh123") + assert rows == [] + + +class TestTempViews: + def test_create_temp_view_returns_fqn(self): + from server.utils import create_temp_view + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_result.manifest.schema.columns = [] + mock_result.result = None + ws.statement_execution.execute_statement.return_value = mock_result + + view_fqn = create_temp_view( + ws, + "my_catalog.my_schema.my_table", + catalog="dqx_mcp", + schema="tmp", + warehouse_id="wh123", + ) + + assert view_fqn.startswith("dqx_mcp.tmp.v_") + call_args = ws.statement_execution.execute_statement.call_args + sql = call_args.kwargs["statement"] + assert "CREATE VIEW" in sql + assert "`my_catalog`.`my_schema`.`my_table`" in sql + + def test_drop_view(self): + from server.utils import drop_view + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_result.manifest.schema.columns = [] + mock_result.result = None + ws.statement_execution.execute_statement.return_value = mock_result + + drop_view(ws, "dqx_mcp.tmp.v_abc123", warehouse_id="wh123") + + call_args = ws.statement_execution.execute_statement.call_args + sql = call_args.kwargs["statement"] + assert "DROP VIEW IF EXISTS" in sql + assert "`dqx_mcp`.`tmp`.`v_abc123`" in sql + + def test_create_temp_view_validates_table_name(self): + from server.utils import create_temp_view + + ws = create_autospec(WorkspaceClient) + + with pytest.raises(ValueError, match="must be fully qualified"): + create_temp_view(ws, "just_a_table", catalog="c", schema="s", warehouse_id="wh") + + @pytest.mark.parametrize( + "malicious_name", + [ + "cat.sch.t; DROP TABLE x", + "cat.sch.t WHERE 1=1 UNION SELECT * FROM secrets", + "cat.sch.t`; --", + "cat.sch.t OR 1=1", + "cat`.`sch`.`t", + "cat.sch.t\nDROP TABLE x", + ], + ) + def test_create_temp_view_rejects_sql_injection(self, malicious_name): + from server.utils import create_temp_view + + ws = create_autospec(WorkspaceClient) + + with pytest.raises(ValueError, match="Invalid|must be fully qualified"): + create_temp_view(ws, malicious_name, catalog="c", schema="s", warehouse_id="wh") + + def test_create_temp_view_backtick_quotes_identifiers(self): + from server.utils import create_temp_view + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_result.manifest.schema.columns = [] + mock_result.result = None + ws.statement_execution.execute_statement.return_value = mock_result + + create_temp_view(ws, "my_cat.my_sch.my_table", catalog="dqx", schema="tmp", warehouse_id="wh") + + sql = ws.statement_execution.execute_statement.call_args.kwargs["statement"] + assert "`my_cat`.`my_sch`.`my_table`" in sql + assert "`dqx`.`tmp`." in sql + + def test_drop_view_swallows_errors(self): + from server.utils import drop_view + + ws = create_autospec(WorkspaceClient) + ws.statement_execution.execute_statement.side_effect = RuntimeError("connection lost") + + # Should not raise + drop_view(ws, "dqx_mcp.tmp.v_abc123", warehouse_id="wh123") + + +class TestSubmitJobAsync: + def test_returns_run_id_and_triggers_runner_job(self): + from server.utils import submit_job_async + + ws = create_autospec(WorkspaceClient) + ws.jobs.run_now.return_value = MagicMock(run_id=123) + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch("server.utils._maybe_sweep_stale_views"), + patch.dict("os.environ", _JOB_ID_ENV), + ): + run_id = submit_job_async("profile_table", {"view_name": "c.s.v_abc"}) + + assert run_id == 123 + ws.jobs.run_now.assert_called_once() + assert ws.jobs.run_now.call_args.kwargs["job_id"] == 42 + + def test_passes_operation_and_params_as_notebook_params(self): + from server.utils import submit_job_async + + ws = create_autospec(WorkspaceClient) + ws.jobs.run_now.return_value = MagicMock(run_id=123) + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch("server.utils._maybe_sweep_stale_views"), + patch.dict("os.environ", _JOB_ID_ENV), + ): + submit_job_async("profile_table", {"view_name": "c.s.v_abc", "columns": ["a"]}) + + notebook_params = ws.jobs.run_now.call_args.kwargs["notebook_params"] + assert notebook_params["operation"] == "profile_table" + assert json.loads(notebook_params["params"]) == {"view_name": "c.s.v_abc", "columns": ["a"]} + + def test_raises_when_no_job_id(self): + from server.utils import submit_job_async + + ws = create_autospec(WorkspaceClient) + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch("server.utils._maybe_sweep_stale_views"), + patch.dict("os.environ", {}, clear=True), + ): + with pytest.raises(RuntimeError, match="DQX_RUNNER_JOB_ID not set"): + submit_job_async("profile_table", {"view_name": "c.s.v_abc"}) + ws.jobs.run_now.assert_not_called() + + +class TestGetRunStatus: + def test_completed_returns_result_with_table_name(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.SUCCESS) + output = MagicMock(spec=RunOutput) + output.notebook_output = MagicMock(spec=NotebookOutput) + # table_name is echoed by the runner into its result; get_run_status returns it as-is. + output.notebook_output.result = json.dumps({"profiles": [], "table_name": "c.s.t"}) + ws.jobs.get_run_output.return_value = output + + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(123) + + assert result["status"] == "completed" + assert result["run_id"] == 123 + assert result["result"]["profiles"] == [] + assert result["result"]["table_name"] == "c.s.t" + ws.jobs.get_run_output.assert_called_once_with(456) + + def test_does_not_drop_view_or_keep_state(self): + # Cleanup moved into the runner job; get_run_status must not attempt any view drop. + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.SUCCESS) + output = MagicMock(spec=RunOutput) + output.notebook_output = MagicMock(spec=NotebookOutput) + output.notebook_output.result = json.dumps({"ok": True}) + ws.jobs.get_run_output.return_value = output + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch("server.utils.drop_view") as mock_drop, + ): + result = get_run_status(123) + + assert result["status"] == "completed" + mock_drop.assert_not_called() + + def test_failed_run_returns_error(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.FAILED) + + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(123) + + assert result["status"] == "failed" + assert "boom" in result["error"] + ws.jobs.get_run_output.assert_not_called() + + +class TestSweepStaleViews: + def test_drops_only_views_older_than_ttl(self): + import time + from server.utils import sweep_stale_views + + now = int(time.time()) + ws = create_autospec(WorkspaceClient) + rows = [ + {"viewName": f"v_{now - 100000}_abc123"}, # stale -> drop + {"viewName": f"v_{now}_def456"}, # fresh -> keep + {"viewName": "some_other_table"}, # not a temp view -> ignore + ] + + with ( + patch("server.utils.execute_sql", return_value=rows), + patch("server.utils.drop_view") as mock_drop, + ): + dropped = sweep_stale_views(ws, "cat", "tmp", "wh", ttl_seconds=3600) + + assert dropped == 1 + mock_drop.assert_called_once_with(ws, f"cat.tmp.v_{now - 100000}_abc123", warehouse_id="wh") + + def test_returns_zero_when_listing_fails(self): + from server.utils import sweep_stale_views + + ws = create_autospec(WorkspaceClient) + with ( + patch("server.utils.execute_sql", side_effect=RuntimeError("no warehouse")), + patch("server.utils.drop_view") as mock_drop, + ): + dropped = sweep_stale_views(ws, "cat", "tmp", "wh") + + assert dropped == 0 + mock_drop.assert_not_called() + + def test_still_running_returns_running(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + run = MagicMock(spec=Run) + run.state = MagicMock(spec=RunState) + run.state.life_cycle_state = RunLifeCycleState.RUNNING + ws.jobs.get_run.return_value = run + + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(123) + + assert result["status"] == "running" + assert result["run_id"] == 123 + # Single non-blocking poll — exactly one get_run call, no internal wait. + ws.jobs.get_run.assert_called_once_with(123) + + +class TestOBOAuthMiddleware: + @pytest.mark.anyio + async def test_extracts_token_and_email(self): + from server.utils import OBOAuthMiddleware, _user_token_var, _user_email_var + + captured = {} + + async def app(scope, receive, send): + captured["token"] = _user_token_var.get(None) + captured["email"] = _user_email_var.get(None) + + middleware = OBOAuthMiddleware(app) + scope = { + "type": "http", + "headers": [ + (b"x-forwarded-access-token", b"user-token"), + (b"x-forwarded-email", b"user@example.com"), + ], + } + + with patch.dict("os.environ", {"DATABRICKS_HOST": "https://host.com"}): + await middleware(scope, None, None) + + assert captured["token"] == ("https://host.com", "user-token") + assert captured["email"] == "user@example.com" + + @pytest.mark.anyio + async def test_no_token_sets_none(self): + from server.utils import OBOAuthMiddleware, _user_token_var, _user_email_var + + captured = {} + + async def app(scope, receive, send): + captured["token"] = _user_token_var.get(None) + captured["email"] = _user_email_var.get(None) + + middleware = OBOAuthMiddleware(app) + scope = {"type": "http", "headers": []} + + await middleware(scope, None, None) + + assert captured["token"] is None + assert captured["email"] is None + + @pytest.mark.anyio + async def test_passes_through_non_http(self): + from server.utils import OBOAuthMiddleware + + called = {"count": 0} + + async def app(scope, receive, send): + called["count"] += 1 + + middleware = OBOAuthMiddleware(app) + await middleware({"type": "lifespan"}, None, None) + + assert called["count"] == 1 + + @pytest.mark.anyio + async def test_honors_inbound_request_id(self): + from server.utils import OBOAuthMiddleware, _request_id_var + + captured = {} + + async def app(scope, receive, send): + captured["rid"] = _request_id_var.get(None) + + middleware = OBOAuthMiddleware(app) + scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": [(b"x-request-id", b"trace-123")]} + await middleware(scope, None, None) + + assert captured["rid"] == "trace-123" + + @pytest.mark.anyio + async def test_generates_request_id_when_absent(self): + from server.utils import OBOAuthMiddleware, _request_id_var + + captured = {} + + async def app(scope, receive, send): + captured["rid"] = _request_id_var.get(None) + + middleware = OBOAuthMiddleware(app) + await middleware({"type": "http", "method": "GET", "path": "/mcp", "headers": []}, None, None) + + assert captured["rid"] and len(captured["rid"]) > 0 + + @pytest.mark.anyio + async def test_resets_context_after_request(self): + # Context must not leak across requests on a reused ASGI worker. + from server.utils import OBOAuthMiddleware, _user_email_var, _user_token_var, _request_id_var + + async def app(scope, receive, send): + pass + + middleware = OBOAuthMiddleware(app) + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"x-forwarded-email", b"u@example.com"), (b"x-forwarded-access-token", b"tok")], + } + with patch.dict("os.environ", {"DATABRICKS_HOST": "https://host.com"}): + await middleware(scope, None, None) + + assert _user_email_var.get(None) is None + assert _user_token_var.get(None) is None + assert _request_id_var.get(None) is None + + @pytest.mark.anyio + async def test_captures_response_status(self): + from server.utils import OBOAuthMiddleware + + sent = [] + + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + async def send(message): + sent.append(message) + + middleware = OBOAuthMiddleware(app) + scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []} + await middleware(scope, None, send) + + # send_wrapper must forward every message untouched to the real send. + assert [m["type"] for m in sent] == ["http.response.start", "http.response.body"] diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock new file mode 100644 index 000000000..c3ff53383 --- /dev/null +++ b/mcp-server/uv.lock @@ -0,0 +1,1542 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[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 = "P7D" + +[options.exclude-newer-package] +databricks-connect = false +databricks-sdk = false + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[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", 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", upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", upload-time = "2026-03-09T20:51:29.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", upload-time = "2026-03-09T20:51:27.33Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", upload-time = "2025-12-26T15:22:00.221Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 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", upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e7/3e26855c046ac527cf94d890f6698e703980337f22ea7097e02b35b910f9/cyclopts-4.10.0.tar.gz", hash = "sha256:0ae04a53274e200ef3477c8b54de63b019bc6cd0162d75c718bf40c9c3fb5268", upload-time = "2026-03-14T14:09:31.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/06/d68a5d5d292c2ad2bc6a02e5ca2cb1bb9c15e941ab02f004a06a342d7f0f/cyclopts-4.10.0-py3-none-any.whl", hash = "sha256:50f333382a60df8d40ec14aa2e627316b361c4f478598ada1f4169d959bf9ea7", upload-time = "2026-03-14T14:09:32.504Z" }, +] + +[[package]] +name = "databricks-sdk" +version = "0.99.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/28/d53ce4c21ab197cacdeb65bcdcba3f8098a1b6b88052b72b60348f6b8091/databricks_sdk-0.99.0.tar.gz", hash = "sha256:13ae35b064277074a79fcd5265260e92b352b5eb4e950438dcee895951bd86fd", upload-time = "2026-03-12T08:16:12.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/3a/8593cae357aac76142da1940ad7a965ea50f2767bb958820839be675811b/databricks_sdk-0.99.0-py3-none-any.whl", hash = "sha256:c0a6740bf21d430daa85461e193197ce1d0ac12209ce20e3cb9b67b32fe2bcfd", upload-time = "2026-03-12T08:16:11.155Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastmcp" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", upload-time = "2026-03-14T19:12:20.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", upload-time = "2026-03-14T19:12:22.736Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", upload-time = "2026-03-06T21:53:06.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", upload-time = "2026-03-06T21:52:38.304Z" }, +] + +[[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", 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", 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", 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", 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", 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", 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", 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", upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", upload-time = "2026-03-07T15:46:04.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", upload-time = "2026-03-07T15:46:03.515Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[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", 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", upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", upload-time = "2026-03-03T09:56:46.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", upload-time = "2026-03-03T09:56:45.39Z" }, +] + +[[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", 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", upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mcp-dqx" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "databricks-sdk" }, + { name = "fastmcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "databricks-sdk", specifier = ">=0.60.0" }, + { name = "fastmcp", specifier = ">=2.12.5" }, + { name = "starlette" }, + { name = "uvicorn", specifier = ">=0.34.2" }, +] + +[[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", 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", upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", upload-time = "2026-02-20T08:46:59.536Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", 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", upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", upload-time = "2026-03-12T17:15:30.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", upload-time = "2026-03-12T17:15:29.257Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[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", 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", upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", upload-time = "2026-02-28T11:24:32.984Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.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/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", 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", upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", upload-time = "2026-02-27T17:40:58.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", upload-time = "2026-02-27T17:40:56.804Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index edb73dfa7..8bada3dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -190,18 +190,18 @@ markdown = { header_level_by_type = { Method = 3, Function = 3, Class = 2, Modul profile = "black" [tool.mypy] -# ``app/*`` is a self-contained uv sub-project (``app/pyproject.toml``) -# with its own venv, its own mypy config, and its own ``app/tests/`` -# suite. Only mypy is opted out at the library level — to avoid a -# "Duplicate module named 'tests'" collision between the library's own -# ``tests/`` package and ``app/tests/`` (both ship ``__init__.py``). +# ``app/*`` and ``mcp-server/*`` are self-contained sub-projects with +# their own venvs, configs, and ``tests/`` suites. Only mypy is opted +# out at the library level — to avoid a "Duplicate module named 'tests'" +# collision between the library's own ``tests/`` package and +# ``app/tests/`` / ``mcp-server/tests/`` (all ship ``__init__.py``). # Type-check the app via ``make app-check`` (runs ``bun run tsc -b`` + -# ``basedpyright`` directly) instead. Black and ruff still traverse into ``app/`` from the root — -# ruff picks up the app's own ``[tool.ruff]`` config automatically, and -# black uses the same ``line-length = 120`` setting in both, so the -# library-level ``make fmt`` / ``make lint`` formats and lints the app -# alongside the library. -exclude = ['venv', '.venv', 'demos/*', 'tests/e2e/*', 'app/*'] +# ``basedpyright`` directly) instead. Black and ruff still traverse +# into ``app/`` and ``mcp-server/`` from the root — ruff picks up each +# project's own ``[tool.ruff]`` config automatically, and black uses +# the same ``line-length = 120`` setting, so the library-level +# ``make fmt`` / ``make lint`` formats and lints them alongside the library. +exclude = ['venv', '.venv', 'demos/*', 'tests/e2e/*', 'app/*', 'mcp-server/*'] [[tool.mypy.overrides]] # External packages without PEP 561 type stubs (unavoidable - these are third-party optional dependencies) @@ -217,7 +217,7 @@ markers = [ "anomaly: marks tests as anomaly detection tests", "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] -pythonpath = ["src", "app/src"] +pythonpath = ["src", "app/src", "mcp-server"] [tool.black] @@ -230,7 +230,7 @@ extend-exclude = 'demos/|tests/e2e/notebooks' cache-dir = ".venv/ruff-cache" target-version = "py310" line-length = 120 -exclude = ["demos/*", "tests/e2e/notebooks", "ui/*"] +exclude = ["demos/*", "tests/e2e/notebooks", "ui/*", "mcp-server/notebooks/*"] [tool.ruff.lint.isort] known-first-party = ["databricks.labs.dqx"] @@ -257,7 +257,7 @@ exclude_lines = [ # License: https://github.com/google/styleguide/blob/gh-pages/LICENSE # Add app/src to sys.path for pylint to find databricks_labs_dqx_app -init-hook = "import sys; sys.path.insert(0, 'app/src')" +init-hook = "import sys; sys.path.insert(0, 'app/src'); sys.path.insert(0, 'mcp-server')" # Clear in-memory caches upon conclusion of linting. Useful if running pylint in # a server-like mode. diff --git a/tests/integration_mcp/.codegen.json b/tests/integration_mcp/.codegen.json new file mode 100644 index 000000000..87f2c53ad --- /dev/null +++ b/tests/integration_mcp/.codegen.json @@ -0,0 +1,11 @@ +{ + "version": { + "src/databricks/labs/dqx/__about__.py": "__version__ = \"$VERSION\"" + }, + "toolchain": { + "required": ["make", "uv"], + "pre_setup": ["make dev"], + "prepend_path": ".venv/bin", + "acceptance_path": "tests/integration_mcp" + } +} diff --git a/tests/integration_mcp/__init__.py b/tests/integration_mcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration_mcp/conftest.py b/tests/integration_mcp/conftest.py new file mode 100644 index 000000000..b2f62c268 --- /dev/null +++ b/tests/integration_mcp/conftest.py @@ -0,0 +1,401 @@ +"""Helpers for the DQX MCP server integration test. + +The acceptance harness runs each test in its **own** pytest session (xdist workers on the first +pass, per-test re-runs on retry), so a ``scope="session"`` "deploy once" fixture is re-run per +test — deploying the app many times and colliding on the shared bundle state path. Following the +repo's e2e bundle test (``test_run_dqx_demo_asset_bundle``), the integration test instead OWNS its +deploy + teardown through the context managers below, so a single test == a single deploy. + +Auth is resolved by the SDK from the ambient credentials (see ``workspace_auth``) and handed to +the deploy/teardown scripts and HTTP calls, so no DATABRICKS_TOKEN needs to be set in the env — +it works under the acceptance action's OIDC auth and under a local profile alike. +""" + +import contextlib +import io +import json +import os +import subprocess +import sys +import time +from collections.abc import Callable, Iterator +from pathlib import Path +from uuid import uuid4 + +import pytest +import requests +from databricks.sdk import WorkspaceClient +from databricks.sdk.config import Config + +from tests.constants import TEST_CATALOG + +_MCP_SCRIPTS = Path(__file__).resolve().parents[2] / "mcp-server" / "scripts" + +# Reuse the same Model Serving endpoint the anomaly AI-explanation tests use. +AI_QUERY_ENDPOINT = os.environ.get("DQX_AI_QUERY_TEST_ENDPOINT", "databricks-claude-sonnet-4-5") + +# Catalog the test creates/drops schemas + temp views in. Defaults to the shared TEST_CATALOG +# (what CI uses); override with DQX_MCP_TEST_CATALOG to run locally against a workspace that +# uses a different catalog. +CATALOG = os.environ.get("DQX_MCP_TEST_CATALOG") or TEST_CATALOG + + +def _bearer_from(config) -> str: + """Mint a fresh bearer from an SDK config (refreshes OAuth/metadata tokens as needed).""" + token = (config.authenticate() or {}).get("Authorization", "").removeprefix("Bearer ").strip() + assert token, "could not obtain a workspace bearer token from the SDK config" + return token + + +@pytest.fixture(scope="session") +def workspace_auth() -> "tuple[str, Callable[[], str]]": + """(host, get_token) resolved by the SDK from the ambient auth — the *control-plane* bearer. + + Uses the SDK's default config resolution — the same auth the harness provides: + acceptance-action OIDC env in CI, a profile or env vars locally. ``get_token()`` mints a + **fresh** bearer on each call via ``config.authenticate()`` (the SDK refreshes OAuth / + metadata-service tokens), so the single long end-to-end test never reuses an expired token. + No DATABRICKS_TOKEN needs to be set anywhere. This bearer is for the CLI deploy and for the + Model Serving endpoint (both on the workspace host). The app's /mcp front-door needs a + different bearer — see ``app_auth``. + """ + config = WorkspaceClient().config + _bearer_from(config) # fail fast if auth is broken + return config.host.rstrip("/"), lambda: _bearer_from(config) + + +@pytest.fixture(scope="session") +def app_auth(workspace_auth: "tuple[str, Callable[[], str]]") -> "Callable[[], str]": + """``get_token`` for the app's /mcp front-door, which only accepts OAuth tokens. + + The Databricks Apps front-door honors **OAuth** tokens (user U2M, or service-principal + M2M-with-secret) and rejects the acceptance harness's metadata-service token with 401 — even + though that identity owns the app. This is a token-*type* limitation, not a permission one. + + When ``DQX_MCP_APP_CLIENT_ID`` + ``DQX_MCP_APP_CLIENT_SECRET`` are present (provisioned from the + acceptance vault for a service principal that has CAN_USE on the app), authenticate as that SP + via OAuth M2M, which the front-door accepts. Otherwise fall back to the ambient SDK auth — an + OAuth profile locally also works. MCP-specific env names are used deliberately so that adding + these to the shared vault never changes how other suites' ``WorkspaceClient`` authenticates. + """ + host, ambient_get_token = workspace_auth + client_id = os.environ.get("DQX_MCP_APP_CLIENT_ID") + client_secret = os.environ.get("DQX_MCP_APP_CLIENT_SECRET") + if not (client_id and client_secret): + return ambient_get_token # local OAuth profile (front-door accepts it) + config = Config(host=host, client_id=client_id, client_secret=client_secret, auth_type="oauth-m2m") + _bearer_from(config) # fail fast if the M2M credentials are bad + return lambda: _bearer_from(config) + + +def _script_env(name_prefix: str, secret_scope: str, host: str, token: str) -> dict[str, str]: + return { + **os.environ, + "NAME_PREFIX": name_prefix, + "CONFIG_SECRET_SCOPE": secret_scope, + "DQX_MCP_TEST_CATALOG": CATALOG, + "DATABRICKS_HOST": host, + "DATABRICKS_TOKEN": token, + } + + +def _emitted(stdout: str, key: str) -> str: + """Return the value of the last ``KEY=VALUE`` line ci_deploy.sh printed for ``key``.""" + return next( + (line.split("=", 1)[1].strip() for line in reversed(stdout.splitlines()) if line.startswith(f"{key}=")), + "", + ) + + +@contextlib.contextmanager +def deploy_mcp_app(host: str, get_token: Callable[[], str]) -> Iterator[dict[str, str]]: + """Deploy ONE isolated MCP app, yield {url, service_principal}, and tear it down. + + A context manager (not a fixture) so a single test owns exactly one deploy — the acceptance + harness's per-test sessions defeat session-scoped fixtures (see the module docstring). + Teardown always runs. ``service_principal`` is the app SP's application id (the identity the + runner job runs as; the test grants it write access for the persisting tools). + + A fresh bearer is minted (``get_token()``) for both deploy and teardown — teardown runs after + the long test, so a token minted at deploy time would be expired by then. + """ + name_prefix = f"mcp-dqx-it-{uuid4().hex[:6]}" + secret_scope = f"dqx-config-{name_prefix}" + + def env() -> dict[str, str]: + return _script_env(name_prefix, secret_scope, host, get_token()) + + try: + result = subprocess.run( + ["bash", str(_MCP_SCRIPTS / "ci_deploy.sh")], env=env(), capture_output=True, text=True, check=True + ) + except subprocess.CalledProcessError as exc: + # Surface the FULL deploy output so the real failure is visible (the CLI's errors land on + # stdout; stderr often only carries a benign warning), then clean up partial resources. + sys.stderr.write(f"\n===== ci_deploy.sh STDOUT =====\n{exc.stdout}\n") + sys.stderr.write(f"===== ci_deploy.sh STDERR =====\n{exc.stderr}\n") + subprocess.run(["bash", str(_MCP_SCRIPTS / "ci_destroy.sh")], env=env(), check=False) + detail = f"STDOUT(tail):\n{(exc.stdout or '')[-2500:]}\n\nSTDERR(tail):\n{(exc.stderr or '')[-2500:]}" + raise AssertionError(f"MCP app deploy failed:\n{detail}") from exc + + url = _emitted(result.stdout, "DQX_MCP_SERVER_URL") + assert url, "ci_deploy.sh did not emit DQX_MCP_SERVER_URL" + try: + yield {"url": url, "service_principal": _emitted(result.stdout, "DQX_MCP_APP_SERVICE_PRINCIPAL")} + finally: + subprocess.run(["bash", str(_MCP_SCRIPTS / "ci_destroy.sh")], env=env(), check=False) + + +# --- MCP-over-HTTP client used by the integration test --------------------------------------- + + +def _mcp_request(url: str, token: str, method: str, params: dict) -> dict: + """Issue one JSON-RPC call to the app's /mcp endpoint and return the ``result`` payload.""" + resp = requests.post( + f"{url}/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=120, + ) + resp.raise_for_status() + text = resp.text + if "data:" in text[:32]: # FastMCP can answer as an SSE event; unwrap the JSON line + text = text.split("data:", 1)[1].strip() + body = json.loads(text) + if "error" in body: + raise RuntimeError(f"MCP error: {body['error']}") + return body["result"] + + +def _tool_payload(call_result: dict) -> dict: + """Extract a tool's structured return value (always a dict for DQX tools).""" + if call_result.get("structuredContent"): + return call_result["structuredContent"] + content = call_result.get("content") or [] + return json.loads(content[0]["text"]) if content else call_result + + +class McpClient: + """Thin MCP client for tests: calls tools and resolves the async submit→poll pattern. + + Tools that submit a job return ``{"status": "submitted", "run_id": ...}``; ``call`` polls + ``get_run_result`` until the run is terminal and returns the inner ``result``. Tools that + answer in-process (e.g. ``get_table_schema``, ``get_workflow``) return their payload directly + — ``call`` handles both. + """ + + def __init__(self, url: str, get_token: Callable[[], str]): + self._url = url + self._get_token = get_token # mint a fresh bearer per request (no expiry over a long run) + + def list_tools(self) -> list[dict]: + return _mcp_request(self._url, self._get_token(), "tools/list", {})["tools"] + + def call(self, name: str, arguments: dict | None = None, *, poll: bool = True, timeout: float = 300.0) -> dict: + payload = _tool_payload( + _mcp_request(self._url, self._get_token(), "tools/call", {"name": name, "arguments": arguments or {}}) + ) + if poll and payload.get("status") == "submitted" and payload.get("run_id"): + return self.wait(payload["run_id"], timeout=timeout) + return payload + + def wait(self, run_id: int, *, timeout: float = 300.0, interval: float = 4.0) -> dict: + deadline = time.monotonic() + timeout + last = "unknown" + while True: + status = _tool_payload( + _mcp_request( + self._url, + self._get_token(), + "tools/call", + {"name": "get_run_result", "arguments": {"run_id": run_id}}, + ) + ) + last = status.get("status", "unknown") + if last == "completed": + return status.get("result", {}) + if last == "failed": + raise AssertionError(f"run {run_id} failed: {status.get('error')}") + if time.monotonic() >= deadline: + raise AssertionError(f"run {run_id} not finished within {timeout:.0f}s (last status={last})") + time.sleep(interval) + + +def wait_until_ready(client: McpClient, *, timeout: float = 180.0, interval: float = 5.0) -> None: + """Poll tools/list until the freshly-deployed app is serving. + + A just-deployed app needs a moment before its server answers; until then /mcp can return + 404/5xx — retry those until it lists tools (or time out with the last error). + + A **401/403**, however, is not a "warming up" state: the request authenticated against the + workspace but the Databricks Apps OBO front-door rejected the *identity*. The acceptance + harness authenticates via the metadata service, whose tokens the front-door does not accept — + only OAuth tokens (user U2M, or SP M2M-with-secret) carrying the app's ``user_api_scopes`` are. + Retrying can't change that, so skip with a clear reason rather than burning the timeout and + failing. The suite still runs fully under an OAuth profile locally, and in CI once an OAuth + token is provided to the harness. + """ + deadline = time.monotonic() + timeout + last: Exception | None = None + while True: + try: + client.list_tools() + return + except requests.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status in {401, 403}: + pytest.skip( + f"MCP app front-door rejected the test identity ({status} Unauthorized): it only " + "accepts OAuth tokens, not the acceptance metadata-service token. Set " + "DQX_MCP_APP_CLIENT_ID / DQX_MCP_APP_CLIENT_SECRET (an SP with CAN_USE on the app) " + "to run live in CI — see the app_auth fixture. Runs under an OAuth profile locally." + ) + last = exc + if time.monotonic() >= deadline: + raise AssertionError(f"MCP app not ready within {timeout:.0f}s: {last}") from last + time.sleep(interval) + except (requests.RequestException, RuntimeError) as exc: # other HTTP errors + MCP error bodies + last = exc + if time.monotonic() >= deadline: + raise AssertionError(f"MCP app not ready within {timeout:.0f}s: {last}") from last + time.sleep(interval) + + +# --- Sample "dirty customers" dataset + ODCS contract ---------------------------------------- + +# Mirrors the docs' "Try it with sample data": 10 rows with deliberate quality issues — a null +# id, a duplicate id (3), a null name, an invalid email, two out-of-range ages, a null country, +# and a negative amount. +_CUSTOMERS_ROWS = """ + (1, 'Alice', 'alice@example.com', 34, 'US', DATE'2023-01-15', 120.50), + (2, 'Bob', 'bob@example.com', 41, 'UK', DATE'2023-02-20', 88.00), + (3, 'Charlie', 'charlie@example.com', 29, 'DE', DATE'2023-03-10', 45.25), + (4, NULL, 'dora@example.com', 52, 'US', DATE'2023-04-01', 200.00), + (5, 'Eve', 'not-an-email', 38, 'FR', DATE'2023-05-05', 60.00), + (7, 'Grace', 'grace@example.com', -3, 'IN', DATE'2023-07-08', 30.00), + (8, 'Heidi', 'heidi@example.com', 210, 'US', DATE'2023-08-19', 95.00), + (9, 'Ivan', 'ivan@example.com', 33, NULL, DATE'2023-09-22', -15.00), + (3, 'Charlie', 'charlie@example.com', 29, 'DE', DATE'2023-03-10', 45.25), + (NULL, 'Peggy', 'peggy@example.com', 39, 'US', DATE'2024-01-05', 180.00) +""" + +_CONTRACT_YAML = """\ +kind: DataContract +apiVersion: v3.0.2 +id: urn:datacontract:dqx_mcp_it:customers +name: Customers Data Quality Contract +version: 1.0.0 +status: active +description: + purpose: DQX MCP integration-test contract for the customers table. +schema: + - name: customers + physicalName: customers + physicalType: table + properties: + - name: customer_id + logicalType: integer + physicalType: INT + required: true + unique: true + primaryKey: true + - name: email + logicalType: string + physicalType: STRING + required: true + logicalTypeOptions: + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + - name: age + logicalType: integer + physicalType: INT + required: true + logicalTypeOptions: + minimum: 0 + maximum: 120 + - name: amount + logicalType: number + physicalType: DOUBLE + required: true + logicalTypeOptions: + minimum: 0.0 +""" + + +def _resolve_warehouse_id(client: WorkspaceClient) -> str: + env_wh = os.environ.get("DATABRICKS_WAREHOUSE_ID") + if env_wh: + return env_wh + warehouses = list(client.warehouses.list()) + if not warehouses: + pytest.skip("no SQL warehouse available to seed the demo dataset") + running = [w for w in warehouses if w.state and w.state.value == "RUNNING"] + warehouse_id = (running[0] if running else warehouses[0]).id + assert warehouse_id, "resolved warehouse has no id" + return warehouse_id + + +@contextlib.contextmanager +def seed_demo_data(app_sp: str) -> Iterator[dict[str, str]]: + """Seed the dirty 'customers' table + an ODCS contract, then drop everything on exit. + + Creates a throwaway schema under ``CATALOG``, the sample table, and a UC-volume contract; + grants the app service principal write access on the schema (so ``save_checks`` / + ``apply_checks_and_save_to_table``, which run as the SP, can write) and read on the contract + volume. Yields the fully-qualified names the test uses. + + Builds its own ambient ``WorkspaceClient`` (rather than a static-token one) so its SDK calls — + including the teardown that runs after the long test — refresh the bearer and don't expire. + """ + client = WorkspaceClient() + warehouse_id = _resolve_warehouse_id(client) + schema = f"dqx_mcp_it_{uuid4().hex[:8]}" + fq_schema = f"{CATALOG}.{schema}" + table = f"{fq_schema}.customers" + + def run_sql(statement: str) -> None: + resp = client.statement_execution.execute_statement( + statement=statement, warehouse_id=warehouse_id, wait_timeout="50s" + ) + state = resp.status.state.value if resp.status and resp.status.state else "UNKNOWN" + if state != "SUCCEEDED": + err = resp.status.error if resp.status else None + raise RuntimeError(f"SQL {state}: {err} :: {statement[:120]}") + + run_sql(f"CREATE SCHEMA IF NOT EXISTS {fq_schema}") + run_sql( + f"CREATE TABLE {table} (customer_id INT, name STRING, email STRING, age INT, " + f"country STRING, signup_date DATE, amount DOUBLE)" + ) + run_sql(f"INSERT INTO {table} VALUES {_CUSTOMERS_ROWS.strip()}") + if app_sp: + # The runner job writes as the app SP, so it needs write access on this schema for + # save_checks / apply_checks_and_save_to_table (see the docs' "Write access" note). + run_sql(f"GRANT USE SCHEMA, CREATE TABLE, MODIFY, SELECT ON SCHEMA {fq_schema} TO `{app_sp}`") + + # The contract is read by the runner job (as the app SP), so it must live somewhere the SP + # can read — a UC volume in the same schema, with READ VOLUME granted to the SP. + run_sql(f"CREATE VOLUME IF NOT EXISTS {fq_schema}.contracts") + if app_sp: + run_sql(f"GRANT READ VOLUME ON VOLUME {fq_schema}.contracts TO `{app_sp}`") + contract_path = f"/Volumes/{CATALOG}/{schema}/contracts/customers_contract.yaml" + client.files.upload(contract_path, io.BytesIO(_CONTRACT_YAML.encode()), overwrite=True) + + try: + yield { + "table": table, + "schema": fq_schema, + "contract": contract_path, + "checks_table": f"{fq_schema}.customers_checks", + "clean_table": f"{fq_schema}.customers_clean", + "quarantine_table": f"{fq_schema}.customers_quarantine", + "service_principal": app_sp, + } + finally: + try: + run_sql(f"DROP SCHEMA IF EXISTS {fq_schema} CASCADE") + except Exception: # noqa: BLE001 — best-effort cleanup, never fail the session on teardown + sys.stderr.write(f"warning: failed to drop schema {fq_schema}\n") diff --git a/tests/integration_mcp/test_mcp_server.py b/tests/integration_mcp/test_mcp_server.py new file mode 100644 index 000000000..84182d9f8 --- /dev/null +++ b/tests/integration_mcp/test_mcp_server.py @@ -0,0 +1,215 @@ +"""End-to-end integration test for the deployed DQX MCP server. + +ONE self-contained test that deploys an isolated MCP app, exercises every tool against a seeded +dirty 'customers' table, and tears everything down. It is a single test (rather than many tests +sharing a fixture) on purpose: the acceptance harness runs each test in its own pytest session, +so a session-scoped "deploy once" fixture is re-run per test — see tests/integration_mcp/ +conftest.py. Mirrors the repo's e2e bundle test (test_run_dqx_demo_asset_bundle), which likewise +owns its deploy + teardown. + +Deterministic tool assertions use explicit error-level rules so failure counts are exact; the +agent-in-the-loop check runs last (skipped if the serving endpoint is unreachable). +""" + +import json +from collections.abc import Callable + +import requests + +from tests.integration_mcp.conftest import ( + AI_QUERY_ENDPOINT, + McpClient, + _mcp_request, + _tool_payload, + deploy_mcp_app, + seed_demo_data, + wait_until_ready, +) + +# The 10-row sample table (conftest._CUSTOMERS_ROWS) has exactly these error-level failures: +# - customer_id NULL ....... 1 row (is_not_null) +# - age outside [0, 120] ... 2 rows (-3 and 210) +# - name NULL .............. 1 row (is_not_null_and_not_empty) +# Across 4 distinct rows, so 4 invalid / 6 valid out of 10. +EXPLICIT_CHECKS = [ + {"criticality": "error", "check": {"function": "is_not_null", "arguments": {"column": "customer_id"}}}, + { + "criticality": "error", + "check": {"function": "is_in_range", "arguments": {"column": "age", "min_limit": 0, "max_limit": 120}}, + }, + {"criticality": "error", "check": {"function": "is_not_null_and_not_empty", "arguments": {"column": "name"}}}, +] +EXPECTED_TOTAL_ROWS = 10 +EXPECTED_INVALID_ROWS = 4 + +ALL_TOOLS = { + "get_workflow", + "get_table_schema", + "profile_table", + "generate_rules", + "generate_rules_from_contract", + "list_available_checks", + "validate_checks", + "run_checks", + "save_checks", + "load_checks", + "apply_checks_and_save_to_table", + "get_run_result", +} + + +def _endpoint_reachable(host: str, get_token: Callable[[], str]) -> bool: + try: + resp = requests.post( + f"{host}/serving-endpoints/{AI_QUERY_ENDPOINT}/invocations", + headers={"Authorization": f"Bearer {get_token()}", "Content-Type": "application/json"}, + json={"messages": [{"role": "user", "content": "reply ok"}], "max_tokens": 5, "temperature": 0.0}, + timeout=30, + ) + return resp.status_code == 200 + except requests.RequestException: + return False + + +def _assert_agent_discovers_tools( + host: str, get_token: Callable[[], str], get_app_token: Callable[[], str], url: str, table: str +) -> None: + """Hand a tool-calling model the MCP tool schemas and assert it picks get_table_schema. + + *get_token* authenticates the Model Serving endpoint (workspace host); *get_app_token* + authenticates the app's /mcp front-door (which only accepts OAuth tokens — see app_auth). + """ + oai_tools = [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t.get("description") or t["name"], + "parameters": t["inputSchema"], + }, + } + for t in _mcp_request(url, get_app_token(), "tools/list", {})["tools"] + ] + messages: list[dict] = [ + {"role": "system", "content": "You are a data quality assistant. Use the tools, then give a short answer."}, + {"role": "user", "content": f"What columns does the table {table} have? Use the tools to find out."}, + ] + called_tools: list[str] = [] + final_text = "" + for _turn in range(6): + resp = requests.post( + f"{host}/serving-endpoints/{AI_QUERY_ENDPOINT}/invocations", + headers={"Authorization": f"Bearer {get_token()}", "Content-Type": "application/json"}, + json={"messages": messages, "tools": oai_tools, "max_tokens": 1024, "temperature": 0.0}, + timeout=120, + ) + resp.raise_for_status() + choice = resp.json()["choices"][0] + msg = choice["message"] + messages.append(msg) + if choice.get("finish_reason") == "tool_calls" and msg.get("tool_calls"): + for call in msg["tool_calls"]: + called_tools.append(call["function"]["name"]) + args = json.loads(call["function"]["arguments"] or "{}") + result = _tool_payload( + _mcp_request( + url, get_app_token(), "tools/call", {"name": call["function"]["name"], "arguments": args} + ) + ) + messages.append( + {"role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, default=str)[:4000]} + ) + else: + final_text = msg.get("content") or "" + break + + assert "get_table_schema" in called_tools, f"expected get_table_schema; got {called_tools}" + assert final_text.strip(), "no final answer produced" + assert any(col in final_text.lower() for col in ("order_id", "customer_id", "status", "amount", "column")) + + +def test_mcp_server_end_to_end(workspace_auth, app_auth): + """Deploy the MCP app once and exercise every tool end-to-end against the seeded table.""" + host, get_token = workspace_auth # control-plane bearer: CLI deploy + Model Serving + get_app_token = app_auth # OAuth bearer the app's /mcp front-door accepts + + with deploy_mcp_app(host, get_token) as app, seed_demo_data(app["service_principal"]) as data: + client = McpClient(app["url"], get_app_token) + wait_until_ready(client) # a freshly-deployed app needs a moment before /mcp serves + table = data["table"] + + # 1. Discovery — every tool is exposed. + names = {t["name"] for t in client.list_tools()} + assert ALL_TOOLS <= names, f"missing tools: {ALL_TOOLS - names}" + + # 2. get_workflow (in-process, returns directly). + workflow = client.call("get_workflow") + assert workflow.get("steps"), "get_workflow should describe the recommended steps" + + # 3. list_available_checks — built-ins present. + listed = client.call("list_available_checks") + assert listed["count"] > 0 + assert {"is_not_null", "is_in_range"} <= {c["name"] for c in listed["checks"]} + + # 4. validate_checks — accepts valid, rejects invalid. + assert client.call("validate_checks", {"checks": EXPLICIT_CHECKS})["valid"] is True + bad = [{"criticality": "error", "check": {"function": "not_a_real_check", "arguments": {}}}] + invalid = client.call("validate_checks", {"checks": bad}) + assert invalid["valid"] is False + assert invalid["errors"], "an unknown check function should produce validation errors" + + # 5. get_table_schema (direct SQL via OBO). + schema = client.call("get_table_schema", {"table_name": table}) + columns = {c["name"] for c in schema["columns"]} + assert {"customer_id", "name", "email", "age", "country", "signup_date", "amount"} <= columns + + # 6. profile_table (full scan) -> generate_rules; generated rules must validate. + profile = client.call("profile_table", {"table_name": table, "options": {"sample_fraction": 1.0}}) + assert profile["profiles"], "profiling should return per-column profiles" + generated = client.call("generate_rules", {"profiles": profile["profiles"], "criticality": "error"}) + assert generated["count"] > 0 + assert client.call("validate_checks", {"checks": generated["rules"]})["valid"] is True + + # 7. generate_rules_from_contract (reads the ODCS contract from the UC volume). + from_contract = client.call("generate_rules_from_contract", {"contract_file": data["contract"]}) + assert from_contract["count"] > 0 + assert client.call("validate_checks", {"checks": from_contract["rules"]})["valid"] is True + + # 8. run_checks — flags exactly the known-dirty rows. + run = client.call("run_checks", {"table_name": table, "checks": EXPLICIT_CHECKS}) + assert run["total_rows"] == EXPECTED_TOTAL_ROWS + assert run["invalid_rows"] == EXPECTED_INVALID_ROWS + assert run["valid_rows"] == EXPECTED_TOTAL_ROWS - EXPECTED_INVALID_ROWS + assert run["error_sample"] and run["rule_summary"] + + # 9. save_checks -> load_checks round-trip (writes/reads as the app SP). + saved = client.call( + "save_checks", {"checks": EXPLICIT_CHECKS, "location": data["checks_table"], "mode": "overwrite"} + ) + assert saved["saved"] is True and saved["count"] == len(EXPLICIT_CHECKS) + # grant-on-write: the calling user is granted access to the table backend it created + assert saved.get("access_granted_to"), "save_checks should grant the caller access to the checks table" + loaded = client.call("load_checks", {"location": data["checks_table"]}) + assert loaded["count"] == len(EXPLICIT_CHECKS) + assert {c["check"]["function"] for c in loaded["checks"]} == {c["check"]["function"] for c in EXPLICIT_CHECKS} + + # 10. apply_checks_and_save_to_table — clean / quarantine split persisted. + applied = client.call( + "apply_checks_and_save_to_table", + { + "table_name": table, + "checks": EXPLICIT_CHECKS, + "output_table": data["clean_table"], + "quarantine_table": data["quarantine_table"], + "mode": "overwrite", + }, + ) + assert applied["quarantine_rows"] == EXPECTED_INVALID_ROWS + assert applied["output_rows"] == EXPECTED_TOTAL_ROWS - EXPECTED_INVALID_ROWS + # grant-on-write: the calling user is granted access to both output tables it created + assert applied.get("access_granted_to"), "apply should grant the caller access to the outputs" + assert set(applied.get("granted_tables") or []) == {data["clean_table"], data["quarantine_table"]} + + # 11. Agent-in-the-loop — a real model must discover + invoke a tool (skip if unreachable). + if _endpoint_reachable(host, get_token): + _assert_agent_discovers_tools(host, get_token, get_app_token, app["url"], table)