diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 000000000..92d719ded --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,84 @@ +# 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 + + # No code-coverage upload here on purpose. Unlike the anomaly/integration suites — whose code + # runs *in the pytest process* (databricks-connect) so `coverage` can measure it — this suite + # drives the MCP server in the deployed Databricks App and the runner in a job, both *remote*. + # Nothing under coverage runs locally, so an upload would only report those modules at 0% and + # drag the PR's Codecov numbers down. This mirrors the sibling DQX Studio app (`make app-test`), + # which likewise runs its tests in CI without a Codecov upload. + + # 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 }} + # No runner-SP env var is needed: ci_deploy.sh leaves the runner job's run_as at the + # *deploying identity* (resolved via `databricks current-user me`), so run_as == the job's + # creator and needs no servicePrincipal.user grant. A real deploy would instead pass a + # dedicated least-privilege SP via DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID. diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index afcab47cd..faa72aa23 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -158,3 +158,23 @@ 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: Type-check MCP server + run: make mcp-check + + - name: Run MCP server tests + run: make mcp-test diff --git a/Makefile b/Makefile index 4f1b1ea0d..f89710feb 100644 --- a/Makefile +++ b/Makefile @@ -68,6 +68,15 @@ 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 +# The integration test's deploy (ci_deploy.sh -> bundle deploy) builds the artifact wheels with +# `uv build ../` from inside mcp-server/, so the global *relative* UV_BUILD_CONSTRAINT +# (.build-constraints.txt) would resolve against the wrong directory and fail with +# "File not found: .build-constraints.txt". Pin it to the absolute repo-root path (same fix as +# mcp-deploy). CI doesn't hit this — it runs via the acceptance harness, not make. +mcp-integration: export UV_BUILD_CONSTRAINT := $(CURDIR)/.build-constraints.txt +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 @@ -172,6 +181,60 @@ 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)") + +# Static type-check the MCP server (parity with app-check). basedpyright is not in mcp-server's +# pyproject/uv.lock (like pytest), so inject it ephemerally with ``uv run --with basedpyright`` over +# the synced runtime env. Scoped to ``server`` (the app package): the runner sub-project's +# pyspark/dqx deps aren't in this env — it type-checks separately as its own wheel. ``standard`` mode +# + ``--level error`` config lives in mcp-server/pyproject.toml [tool.basedpyright]. +mcp-check: ## Type-check the MCP server with basedpyright (standard mode, errors only) + cd mcp-server && uv run --with basedpyright basedpyright --level error server + +# One-command MCP server deploy (parity with app-deploy). Ensures the runner-wheel volume +# exists, 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 +# is passed as CATALOG below and injected into the app as the DQX_CATALOG config value (no secret). +# +# Usage: make mcp-deploy PROFILE=my-profile CATALOG=main +# CATALOG is REQUIRED: workspace.artifact_path is the UC volume /Volumes//tmp/dqx_artifacts +# that hosts the runner wheels; it is resolved at deploy time and must pre-exist. It is also the +# catalog the app + runner use for temp views and per-user output schemas. +# 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= CATALOG= [TARGET=] [BUNDLE_VARS=...]"; exit 1) + @test -n "$(CATALOG)" || (echo "Usage: make mcp-deploy PROFILE= CATALOG= [TARGET=] [BUNDLE_VARS=...]"; exit 1) + cd mcp-server && DQX_MCP_CATALOG=$(CATALOG) DATABRICKS_PROFILE=$(PROFILE) ./scripts/ensure_artifacts_volume.sh + cd mcp-server && databricks bundle deploy -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) --var catalog_name=$(CATALOG) $(BUNDLE_VARS) + cd mcp-server && databricks bundle run dqx_setup -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) --var catalog_name=$(CATALOG) $(BUNDLE_VARS) + cd mcp-server && databricks bundle run mcp-dqx -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) --var catalog_name=$(CATALOG) $(BUNDLE_VARS) + +# One-command teardown (nothing in the MCP bundle is destroy-protected, so — unlike the Studio's +# multi-step unbind-then-destroy uninstall — this is a single command). Removes the app + runner/ +# setup jobs, then drops the out-of-band runner-wheel volume. Leaves the .tmp schema and any +# dqx_mcp_ output schemas intact (they may hold user data) — drop those manually if you want a +# fully clean wipe. runner_service_principal_id isn't needed to destroy, so its placeholder is fine. +mcp-destroy: ## Tear down the MCP server (app + jobs + runner-wheel volume). Requires PROFILE + CATALOG + @test -n "$(PROFILE)" || (echo "Usage: make mcp-destroy PROFILE= CATALOG= [TARGET=]"; exit 1) + @test -n "$(CATALOG)" || (echo "Usage: make mcp-destroy PROFILE= CATALOG= [TARGET=]"; exit 1) + cd mcp-server && databricks bundle destroy -p $(PROFILE) $(if $(TARGET),-t $(TARGET)) --auto-approve --var catalog_name=$(CATALOG) $(BUNDLE_VARS) + cd mcp-server && databricks volumes delete $(CATALOG).tmp.dqx_artifacts -p $(PROFILE) 2>/dev/null || true + @echo "Destroyed the MCP app + jobs and the dqx_artifacts volume. The $(CATALOG).tmp schema and any $(CATALOG).dqx_mcp_ output schemas are left intact — drop them manually if you want a full wipe." + ##@ App deploy (require PROFILE=; most also need TARGET=) # Minimum Databricks CLI version required to deploy. The ``postgres_roles`` @@ -251,6 +314,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..0ef85face --- /dev/null +++ b/docs/dqx/docs/guide/dqx_mcp_server.mdx @@ -0,0 +1,367 @@ +--- +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: [Install and deploy](#install-and-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 [Install and deploy](/docs/installation#dqx-mcp-server-installation)): + +```bash +make mcp-deploy PROFILE= CATALOG= BUNDLE_VARS='--var runner_service_principal_id=' +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->>App: OBO pre-check — caller may read/write the target? + App->>Job: app SP submits, job runs as the runner 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: app SP submits, job runs as the runner SP + App-->>Client: run_id + + Note over Client,Job: Poll until complete + Client->>App: get_run_result(run_id) + App->>Job: app SP checks status + Job-->>App: runner writes result file to the UC volume + App-->>Client: status / result (read from the volume) +``` + +**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 runner is a **`python_wheel_task`** (a proper, unit-testable package, mirroring the Studio task runner); its wheel is hosted in a UC volume (`.tmp.dqx_artifacts`) and the setup job grants the runner SP `READ VOLUME` there so the serverless environment can install it. It writes its JSON result to a **UC volume** (`.tmp.mcp_results`) keyed by run id; the client then calls `get_run_result(run_id)`, and the app reads that file back via the Files API (no SQL warehouse needed). If the job is still running, `get_run_result` returns `{"status": "running"}`; if the `run_id` is invalid/expired it returns `{"status": "not_found"}`. + +**Two service principals (why):** the app's auto-created SP is *app-scoped* and **cannot** be a job's `run_as` identity, so the runner job runs as a separate, least-privilege **workspace service principal** (`runner_service_principal_id`). The **app SP** runs the app process (submits the job, polls status, reads result files, sweeps stale views/files); the **runner SP** runs the job (reads through views, applies checks, writes outputs, drops its own view). The job never runs as the deploying human. + +**UC governance:** Tools that read a source table (`profile_table`, `run_checks`, `apply_checks_and_save_to_table`) create a temporary **definer's-rights view** using the **user's** OBO token — so the runner reads *as the calling user*, and if the user can't read the source, view creation fails. For the non-view paths (checks/contract **files**, and **writes** to output/checks tables), the app runs an **OBO pre-check as the caller** before submitting: it confirms the caller can read the file, and that the caller may write the destination (owns an existing table, or can create in the target schema). So the runner SP can never touch something the caller couldn't. Each temp view is dropped by the runner job itself (a periodic sweeper reaps orphans as a backstop), so cleanup does not depend on the client polling. + +## 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 in your private per-user schema † | Runner job | `run_id` | +| `load_checks` | Load previously saved checks (table read via OBO view; file read pre-checked) | OBO + runner job | `run_id` | +| `apply_checks_and_save_to_table` | Apply checks and write valid / quarantine rows to Delta † | OBO view + runner job | `run_id` | +| `get_run_result` | Fetch the result (or `running`/`failed`/`not_found` status) of a submitted run | Reads the result file from the UC volume | Result or status | + +† These tools **write** to your own private, per-user MCP schema (`dqx_mcp_`) — you pass a bare output table *name*, not a destination — and the runner grants you access to what it creates. See [Write access for the persisting tools](#write-access). + +## Install and deploy + +Deploying the MCP server (prerequisites, the one-time **runner service principal**, and the `make mcp-deploy` steps) lives on the installation page: **[DQX MCP Server installation](/docs/installation#dqx-mcp-server-installation)**. + +Once it's deployed, the rest of this guide covers configuration, connecting a client, and usage. + +## 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. +- **The job runs as a least-privilege runner SP, never a human.** The app SP submits the job; the job itself runs as the dedicated `runner_service_principal_id`. Source reads still execute *as the caller* through the definer's-rights views. +- **File reads are gated by an OBO pre-check; writes go to your own private schema.** Before submitting, the app verifies — using *your* UC permissions — that you can read any checks/contract file. The persisting tools (`save_checks` / `apply_checks_and_save_to_table`) don't take a caller-supplied destination at all: outputs land in your own private, per-user MCP schema (`dqx_mcp_`), which the runner SP owns and grants only to you (`ALL PRIVILEGES` + `MANAGE` on the tables, `USE SCHEMA` on the schema). This removes cross-user data leaks and name collisions, and the SP only ever writes where it is guaranteed to have permission. +- **CORS is scoped to your workspace.** The app only reflects credentialed browser origins from `DATABRICKS_HOST` (plus any `DQX_MCP_EXTRA_CORS_ORIGINS`) — not the whole multi-tenant `*.databricksapps.com` domain. +- **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). + +### Bundle variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `runner_service_principal_id` | **Required.** Application id of the workspace SP the runner job runs as (see [Runner service principal](/docs/installation#runner-service-principal-one-time)). The placeholder default is not a real SP. | `00000000-…` | +| `name_prefix` | Prefix for all resource names (app + jobs). Override to deploy an isolated second copy. | `mcp-dqx` | +| `catalog_name` | **Required at deploy time.** Catalog for temp views, per-user output schemas, and the runner-wheel volume (`workspace.artifact_path` = `/Volumes//tmp/dqx_artifacts`). Passed straight into the app config as `DQX_CATALOG` — no secret scope (a catalog name isn't sensitive; UC grants govern access). | `""` | +| `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:** Passed as the `catalog_name` bundle var at deploy time and injected into the app as the `DQX_CATALOG` config env value (and into the setup job). It is **not** stored in a secret — a UC catalog name isn't sensitive, and access is governed by Unity Catalog grants, not by hiding the name. To change it, redeploy with a new `CATALOG=`. +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. **SP permissions:** the setup job grants the app SP and runner SP their temp-schema/volume access, grants the runner SP `CREATE SCHEMA` on the catalog (for per-user output schemas), and grants the app SP `CAN_MANAGE_RUN` on the runner job. + +### Write access for the persisting tools {#write-access} + + +The two tools that **persist** data — `save_checks` and `apply_checks_and_save_to_table` — do **not** take a caller-supplied destination. You pass a bare table *name* (e.g. `customers_clean`), and the runner service principal writes it into your own private, per-user MCP schema: + +``` +.dqx_mcp_. +``` + +`` is the configured MCP catalog (the `catalog_name` deploy var), and `dqx_mcp_` is a schema unique to your identity that the runner **creates on demand and owns**. Because the SP owns the schema it writes to, no per-target grants are needed — the setup job's `CREATE SCHEMA` grant on the catalog is the only prerequisite (applied automatically). + +The runner grants **you** `USE SCHEMA` on your schema and `ALL PRIVILEGES` + `MANAGE` on the tables it creates, so you can read, modify, and drop/alter the outputs from your own pipelines outside the MCP. **Isolation:** each caller is granted access only to *their own* schema, so users can't see or collide with each other's outputs. Ownership stays with the SP so repeat `overwrite` runs keep working and don't depend on a grant you could revoke. + +If you only ever use the read/analyze tools (profile, generate, validate, run), nothing is persisted and no schema is created. + + +## 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 as `customers_checks` so I can reuse them later." | +| `load_checks` | "Load the data quality rules we saved earlier from `.dqx_mcp_.customers_checks` and run them on today's data." | +| `apply_checks_and_save_to_table` | "Apply the checks to `..
`, writing valid rows to `customers_clean` and invalid rows to `customers_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 so I can reuse them later; +- 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 where you persisted them — your private `.dqx_mcp_` schema. A fresh session (or a scheduled pipeline) starts with no checks in context — `load_checks` pulls a previously-saved set back (by its fully-qualified name) so `run_checks` / `apply_checks_and_save_to_table` can use it without re-profiling. The `save_checks` result reports the fully-qualified location to load from. + + +`save_checks` and `apply_checks_and_save_to_table` write to your own private per-user schema (`.dqx_mcp_`), which the runner creates and grants to you — no target grants needed. 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](/docs/installation#mcp-prerequisites)). | +| Setup job: `CREATE SCHEMA` or `GRANT` denied | Missing catalog permissions | Grant the deploying user `USE CATALOG` + `CREATE SCHEMA` + `MANAGE` on the catalog. | +| `bundle deploy`: *"User is not authorized to use this service principal"* | Deployer lacks the **User** role on the runner SP | Grant yourself User on `runner_service_principal_id` (see [Runner service principal](/docs/installation#runner-service-principal-one-time)). | +| 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 file read fails with *"Invalid scope, required scopes: workspace"* | OBO token predates a scope change, or the app lacks `workspace.workspace` | Reconnect the MCP server (re-authorize) to mint a fresh token; confirm the app declares the `workspace.workspace` scope. | +| 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` rejected with *"Invalid output name"* | You passed a dotted/path destination instead of a bare table name | Pass a bare name (e.g. `customers_clean`) — outputs go to your private per-user schema, not a catalog.schema.table you choose (see [Write access](#write-access)). | +| `save_checks` / `apply_checks_and_save_to_table` `run_id` returns `failed` with a `CREATE SCHEMA` permission error | The **runner SP** lacks `CREATE SCHEMA` on the catalog (setup job not run, or run without the runner SP) | Re-run the setup job with `runner_service_principal_id` set — it grants the runner SP `CREATE SCHEMA` (see [Write access](#write-access)). | +| `get_run_result` returns `not_found` | The `run_id` is invalid, expired, or from another job | Re-check the `run_id` returned by the original submit call. | +| A tool's `run_id` returns `failed` | The runner job errored | Read the `error` field (the failed task's message is surfaced), or open **Workflows → Jobs → mcp-dqx-runner**. | +| `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= CATALOG= \ + BUNDLE_VARS='--var runner_service_principal_id=' +``` + +**Uninstall** — none of the resources are destroy-protected, so teardown is a single command: + +```bash +# macOS / Linux (make) +make mcp-destroy PROFILE= CATALOG= + +# Any platform (Databricks CLI only) +cd mcp-server +databricks bundle destroy --auto-approve --var catalog_name= --profile # removes the app + both jobs +databricks volumes delete .tmp.dqx_artifacts --profile # drop the runner-wheel volume +``` + +`bundle destroy` only removes resources **this** bundle deployment tracks — if the workspace also has an `mcp-dqx` app or `mcp-dqx-runner`/`-setup` jobs from a *separate* deploy (e.g. a colleague's, or an isolated `name_prefix` copy), remove those separately. + +The `.tmp` schema (temp views + results volume) and any `dqx_mcp_` output schemas are **not** part of the bundle — remove them manually if desired (`DROP SCHEMA .tmp CASCADE`, and any `.dqx_mcp_*`). + +## 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/installation.mdx b/docs/dqx/docs/installation.mdx index c1e6b8716..832c9415f 100644 --- a/docs/dqx/docs/installation.mdx +++ b/docs/dqx/docs/installation.mdx @@ -575,6 +575,125 @@ Database migrations run automatically on app startup and preserve existing rules `prevent_destroy` is what stops a `databricks bundle destroy` (or a forced replace from a deploy) from wiping your rules, schedules, role mappings, and comments. The multi-step uninstall above is by design — silent data loss is the bigger risk to protect against. +## DQX MCP Server installation + +The [DQX MCP Server](/docs/guide/dqx_mcp_server) exposes DQX as Model Context Protocol (MCP) tools for AI agents (Genie Code, Cursor, Claude Code). This section covers the prerequisites and deployment; see the [guide](/docs/guide/dqx_mcp_server) for architecture, the available tools, configuration, and usage. + +### Prerequisites {#mcp-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 | +| **USE CATALOG** + **CREATE SCHEMA** | the target catalog | setup job (creates `.tmp` + results volume) | `User does not have CREATE SCHEMA` | +| **MANAGE** (or owner) on the catalog | the target catalog | setup job's `GRANT` / `ALTER SCHEMA OWNER` | grant rejected | +| **Service Principal: User** role on the runner SP | the runner SP (below) | `bundle deploy` sets the job's `run_as` | `User is not authorized to use this service principal` | + +An existing Unity Catalog **catalog** must already exist (the setup job creates a schema inside it, not the catalog itself). + +### Runner service principal (one-time) {#runner-service-principal-one-time} + +The runner job runs as a dedicated, least-privilege **workspace service principal** — *not* the app's auto-created SP (which is app-scoped and cannot be a job's `run_as`) and *not* the deploying user. Create it once, then pass its application id as `runner_service_principal_id` at deploy: + +1. Create a workspace service principal (UI: **Settings → Identity and access → Service principals → Add**, or the SCIM API). Note its **Application ID**. +2. Grant your deploying identity the **User** role on it (Settings → that SP → **Permissions**), or `bundle deploy` fails with *"User is not authorized to use this service principal"*. (Workspace admins usually have this implicitly, but confirm.) +3. Give the SP the **workspace-access** entitlement so it can run serverless jobs. +4. The setup job grants the SP the UC privileges it needs automatically — temp schema, results volume, and **`CREATE SCHEMA` on the catalog** so `save_checks` / `apply_checks_and_save_to_table` can create each caller's private per-user output schema (`dqx_mcp_`) on demand. No per-target write grants are needed (see [Write access](/docs/guide/dqx_mcp_server#write-access)). + +### Deploy the MCP server {#mcp-deploy} + +#### What gets deployed + +Deploying the bundle creates three 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 **wheel-task** job with `databricks-labs-dqx` installed; **runs as the runner SP**. Tools submit work to it. | +| Job | `mcp-dqx-setup` | One-time setup: temp schema, results volume, UC grants, and the app-SP job grant. | + +The catalog is passed as the `CATALOG` deploy flag (below) and injected into the app as a plain +`DQX_CATALOG` config value — **no secret scope** (a catalog name isn't sensitive; UC grants govern +access). 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](/docs/guide/dqx_mcp_server#upgrade-and-uninstall). (The dedicated runner SP is created out-of-band and is **not** managed or removed by the bundle.) + +#### 1. Authenticate + +```bash +databricks auth login --host https:// --profile +``` + +#### 2. Deploy + +Both parameters are **required**: `` is the UC catalog that holds the temp views, per-user +output schemas, and the runner-wheel volume (`/Volumes//tmp/dqx_artifacts`) — it's injected +into the app as `DQX_CATALOG` — and `` is the [runner SP](#runner-service-principal-one-time) +(its placeholder default is not a real SP). + +**Any platform (Databricks CLI only — Windows, macOS, Linux; no `make` needed).** Run these from the +`mcp-server/` directory, substituting your values. On Windows use PowerShell or `cmd` (the commands +are identical — only the `<...>` placeholders change): + +```bash +# 1. Create the UC volume that hosts the runner wheels. It must exist BEFORE 'bundle deploy' +# uploads the wheels into it. (Already exists on re-deploy — the "already exists" error is safe.) +databricks schemas create tmp --profile +databricks volumes create tmp dqx_artifacts MANAGED --profile + +# 2. Deploy the bundle (app + runner job + setup job) +databricks bundle deploy --var catalog_name= --var runner_service_principal_id= --profile + +# 3. Run the one-time setup job (UC grants; idempotent) +databricks bundle run dqx_setup --var catalog_name= --var runner_service_principal_id= --profile + +# 4. Deploy and start the app (uses the bundle's inline app config — command + DQX_CATALOG) +databricks bundle run mcp-dqx --var catalog_name= --var runner_service_principal_id= --profile +``` + +**macOS / Linux shortcut.** If you have `make`, the same four steps are wrapped in one command +(step 1 via `scripts/ensure_artifacts_volume.sh`): + +```bash +make mcp-deploy PROFILE= CATALOG= \ + BUNDLE_VARS='--var runner_service_principal_id=' +``` + +Add `-t ` (CLI) or `TARGET=` (make) for a non-default bundle target. + +The **setup job**: +- Creates the `.tmp` schema (for temp views) and the `.tmp.mcp_results` volume (for result files) +- Keeps the **deploying principal** as the schema owner (so setup can create the volume and manage grants on every run) +- Grants `USE CATALOG` to all users, the app SP, and the runner SP +- Grants `USE SCHEMA` + `CREATE TABLE` on the tmp schema to all users (so their OBO token can create views) +- Grants `USE SCHEMA` + `SELECT` + `MANAGE` on the tmp schema to the app SP and the runner SP (`MANAGE` lets them drop the OBO-created temp views without owning the schema) +- Grants the **runner SP** `CREATE SCHEMA` on the catalog (so `save_checks` / `apply_checks_and_save_to_table` can create each caller's private per-user output schema `dqx_mcp_`) +- Grants `READ VOLUME` + `WRITE VOLUME` on the results volume to both SPs +- Grants the **app SP** `CAN_MANAGE_RUN` on the runner job (so the app can submit runs and poll results) +- Grants the **runner SP** `READ VOLUME` on the `.tmp.dqx_artifacts` volume (so the serverless env can install the runner wheel) + +The `dqx_artifacts` volume that hosts the runner wheels must exist **before** `bundle deploy` (step 1 above) because `bundle deploy` uploads the wheels into it — that's why the catalog is required at deploy time. On macOS/Linux `make mcp-deploy` runs step 1 for you via `scripts/ensure_artifacts_volume.sh`; on any platform the two `databricks schemas create` / `databricks volumes create` commands do the same thing. + +#### 3. 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 +``` + ## Installing Dashboard DQX data quality dashboard can be used to monitor data quality across all tables monitored by DQX. 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/databricks.yml b/mcp-server/databricks.yml new file mode 100644 index 000000000..0a686bc98 --- /dev/null +++ b/mcp-server/databricks.yml @@ -0,0 +1,191 @@ +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 + per-user output schemas AND the UC volume that hosts the runner wheels. + # REQUIRED at deploy time: workspace.artifact_path (below) is built from it and is resolved during + # `bundle deploy`, so an empty value produces an invalid `/Volumes//tmp/...` path. It is injected + # into the app as the DQX_CATALOG config env value (no secret scope — a catalog name isn't + # sensitive). Pass e.g. --var catalog_name= (or CATALOG= via make mcp-deploy). + # The placeholder default keeps the bundle syntactically valid but every real deploy must override it. + catalog_name: + default: "" + # Application ID of the **dedicated workspace service principal** the runner job runs as. + # This is NOT the app's auto-created SP — an app-scoped SP cannot be a job run_as identity, + # so a separate workspace SP is required (see the "Runner service principal" section of the DQX + # MCP Server installation docs). The placeholder + # default keeps the bundle syntactically valid; every real deploy MUST override it, e.g. + # databricks bundle deploy --var runner_service_principal_id= + # The deploying identity needs the "Service Principal: User" role on this SP (admins have it + # implicitly) or `bundle deploy` fails with "User is not authorized to use this service principal". + runner_service_principal_id: + default: "00000000-0000-0000-0000-000000000000" + +# Host the runner wheels in a UC volume (not the deployer's personal workspace folder). The +# runner job runs as a least-privilege SP; a UC volume grant (READ VOLUME, applied by setup.py) +# is explicit and volume-wide, so it doesn't depend on workspace-ACL inheritance from someone's +# home directory. The volume MUST already exist before `bundle deploy` uploads artifacts into it +# — scripts/ensure_artifacts_volume.sh (run by ci_deploy.sh and `make mcp-deploy`) creates it. +workspace: + artifact_path: "/Volumes/${var.catalog_name}/tmp/dqx_artifacts" + +resources: + jobs: + dqx_runner: + name: "${var.name_prefix}-runner" + max_concurrent_runs: 10 + # Run the per-request data job as a dedicated, least-privilege workspace SP — NOT the + # deploying user. Otherwise every caller's job would execute with the deployer's (often + # admin) permissions on the non-view paths (file reads, output writes). Source-table reads + # stay governed by the caller via definer's-rights views regardless of this identity. + run_as: + service_principal_name: ${var.runner_service_principal_id} + tasks: + # A python_wheel_task (not a notebook_task): the job runs as the dedicated runner SP, and + # the runner is a proper unit-testable package (mirrors the DQX Studio task runner). The + # run_as SP still needs read access to the wheel — it lives in the dqx_artifacts UC volume + # (workspace.artifact_path above) and the setup job grants the SP READ VOLUME there (see + # notebooks/setup.py). The entry point writes its JSON result to the results UC volume + # keyed by {{job.run_id}}; the app reads it back. + - task_key: dqx_run + python_wheel_task: + package_name: dqx_mcp_runner + entry_point: dqx-mcp-runner + parameters: + - "--operation" + - "{{job.parameters.operation}}" + - "--params" + - "{{job.parameters.params}}" + - "--results-volume" + - "{{job.parameters.results_volume}}" + - "--run-id" + - "{{job.run_id}}" + environment_key: default + environments: + - environment_key: default + spec: + environment_version: "2" + dependencies: + # The runner wheel (this sub-project) + the DQX library wheel built from THIS repo + # (see artifacts), not a published PyPI release, so the runner exercises in-repo + # (possibly unreleased) source. The [datacontract] extra's dependency is listed + # explicitly because pip extras can't be applied to a wheel path. + # + # The [llm] extra (dspy) is intentionally NOT installed — the runner stays lean and + # deterministic, mirroring the DQX Studio task runner. As a result the + # generate_rules_from_contract tool is deterministic-only: it does not process the + # contract's free-text expectations (DQX's LLM "text rules" path). Enabling that is a + # follow-up: add the dspy/litellm deps from the DQX [llm] extra here, plumb an LLM + # endpoint config through to the runner, and re-expose the parameter in server/tools.py. + - ./.build/dqx_mcp_runner-*.whl + - ./.build/databricks_labs_dqx-*.whl + - datacontract-cli>=0.11.1,<1.0 + parameters: + - name: operation + default: "" + - name: params + default: "{}" + # Full UC-volume path for result files, e.g. /Volumes//tmp/mcp_results. The app + # sets this per-submit from its catalog config; the runner writes .json there. + - name: results_volume + default: "" + # Email of the user who submitted this run (from the OBO X-Forwarded-Email header). Not read + # by the runner — it's recorded on the run so get_run_result can authorize the reader: only + # the submitter may fetch a run's result (results can contain their governed source data). + - name: requesting_user + default: "" + + # 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}" + users_group: ${var.users_group} + # The runner job's run_as SP — setup grants it the tmp-schema access it needs. + runner_service_principal_id: "${var.runner_service_principal_id}" + # The runner job id — setup grants the app SP CAN_MANAGE_RUN on it so the app can + # submit runs and poll results. (The app→job resource binding alone did not reliably + # land this grant in the job ACL, so setup applies it explicitly.) + runner_job_id: "${resources.jobs.dqx_runner.id}" + 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 + # 'workspace.workspace' lets the user's OBO token read/stat/write workspace-file paths + # (e.g. a contract or checks YAML under /Workspace or /Users). Without it the OBO + # read/write pre-checks for workspace-file backends fail with 403 "Invalid scope". + - workspace.workspace + + # App runtime config lives here (not a static app.yaml) so bundle variables interpolate into + # it at deploy time — mirrors the DQX Studio bundle. This replaces app.yaml (which cannot + # reference ${var.*}). + config: + command: ["python", "server/main.py"] + env: + # DQX_CATALOG is a plain (non-secret) value: a Unity Catalog *name* is not sensitive — + # access is governed by UC grants, not by hiding the name — so it does not belong in a + # secret scope. It is set at deploy time from the catalog_name bundle var (the same value + # used for the artifacts-volume path), so there is nothing to configure out-of-band. + - name: DQX_CATALOG + value: "${var.catalog_name}" + - name: DQX_TMP_SCHEMA + value: "tmp" + # Resource-backed: the runner job id comes from the app→job binding below. + - name: DQX_RUNNER_JOB_ID + value_from: "dqx-runner-job" + + resources: + - name: dqx-runner-job + job: + id: ${resources.jobs.dqx_runner.id} + permission: "CAN_MANAGE_RUN" + + permissions: + - level: CAN_USE + group_name: ${var.users_group} + +sync: + include: + - "notebooks/**" + - ".build/**" + +# Build the wheels the runner job installs from THIS branch's source (not published PyPI releases): +# the DQX library wheel (from the repo root) and the MCP runner wheel (this sub-project's ./runner). +# 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 + dqx_mcp_runner_wheel: + build: uv build ./runner --wheel --out-dir ./.build + +targets: + dev: + default: true diff --git a/mcp-server/notebooks/setup.py b/mcp-server/notebooks/setup.py new file mode 100644 index 000000000..3b14cae4b --- /dev/null +++ b/mcp-server/notebooks/setup.py @@ -0,0 +1,262 @@ +# 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 + - runner SP → USE CATALOG, CREATE SCHEMA (creates each caller's private per-user output + schema dqx_mcp_ on demand for save_checks / apply_checks_and_save_to_table). + Only granted when provided. + Schema level (catalog.tmp) — the deploy principal OWNS the schema (so setup can create the + results volume + manage grants on every run); the SPs get MANAGE, which is enough to DROP + temp views in UC (owner / parent-schema owner / MANAGE can DROP): + - users → USE SCHEMA, CREATE TABLE (so OBO token can create views) + - app SP → USE SCHEMA, SELECT, MANAGE (in-app stale-view sweeper) + - runner SP → USE SCHEMA, SELECT, MANAGE (the runner job runs as this dedicated + workspace SP: it reads through definer's-rights views and drops its own + temp view at the end of each run). Only granted when provided. + Volume level (catalog.tmp.mcp_results): + - app SP, runner SP → READ VOLUME, WRITE VOLUME (result-file exchange) + Volume level (catalog.tmp.dqx_artifacts) — hosts the runner wheels (workspace.artifact_path): + - runner SP → READ VOLUME (so the serverless env can install the runner wheel). The volume is + created out-of-band before `bundle deploy` (scripts/ensure_artifacts_volume.sh); setup only + ensures it exists and applies the grant. + +Note: outputs (apply_checks_and_save_to_table / save_checks) go to the caller's own SP-owned +per-user schema (dqx_mcp_), created on demand via the runner SP's CREATE SCHEMA grant — no +per-deployment write grants are needed. The runner SP still needs read access to any contract/checks +files callers load; keep those in locations the runner SP can reach. + +Parameters: + - catalog_name: UC catalog for temp views + per-user output schemas (required; passed as a + bundle var at deploy time — the catalog name is not sensitive, so no secret scope) + - 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') + - runner_service_principal_id: application id of the runner job's run_as SP (optional) +""" + +# 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_ ]+$") +# Service principal application ids are UUIDs (hex + hyphens). Validated separately because the +# identifier charset above disallows hyphens. +_SP_ID_RE = re.compile(r"^[0-9a-fA-F-]+$") +_PLACEHOLDER_SP_ID = "00000000-0000-0000-0000-000000000000" + + +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 + + +def _validate_sp_id(value, label): + """Validate a service-principal application id (UUID) before SQL interpolation.""" + if not _SP_ID_RE.match(value): + raise ValueError(f"Invalid {label}: '{value}'. Expected a service principal application id (UUID).") + return value + + +# A user/SP/group principal (email addresses, application ids, group names) for GRANT/ALTER OWNER. +_SAFE_PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9._%+\-@ ]+$") + + +def _validate_principal(value, label): + """Validate a UC principal name before SQL interpolation (backtick-breakout guard).""" + if not _SAFE_PRINCIPAL_RE.match(value): + raise ValueError(f"Invalid {label}: '{value}'.") + 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("runner_service_principal_id", "") +dbutils.widgets.text("runner_job_id", "") + +catalog_name = dbutils.widgets.get("catalog_name") +app_name = dbutils.widgets.get("app_name") +users_group = dbutils.widgets.get("users_group") +runner_sp = dbutils.widgets.get("runner_service_principal_id").strip() +runner_job_id = dbutils.widgets.get("runner_job_id").strip() + +# catalog_name is passed as a bundle var at deploy time (required — it also builds the artifacts +# volume path). No secret scope is involved: the catalog name is not sensitive. +if not catalog_name: + raise ValueError("catalog_name must be provided (pass --var catalog_name= at deploy time)") + +_validate_identifier(catalog_name, "catalog_name") +# users_group is interpolated into the GRANT statements below, so it must be validated too +# (a backtick would otherwise break out of the quoted identifier). app_name is NOT validated +# here: it is only passed to ws.apps.get() (never interpolated into SQL) and legitimately +# contains hyphens (e.g. 'mcp-dqx'), which this identifier charset disallows. +_validate_identifier(users_group, "users_group") + +# The runner job's run_as service principal (a dedicated workspace SP, distinct from the app SP). +# Empty or the all-zeros placeholder means it was not configured for this deploy — skip its grants +# (the job's run_as would then be misconfigured, which the bundle deploy surfaces separately). +runner_sp_configured = bool(runner_sp) and runner_sp != _PLACEHOLDER_SP_ID +if runner_sp_configured: + _validate_sp_id(runner_sp, "runner_service_principal_id") + +logger.info( + f"Setting up DQX MCP: catalog={catalog_name}, app={app_name}, users_group={users_group}, " + f"runner_sp={'' if runner_sp_configured else ''}" +) + +# 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" +# Constant today, but validated as defense-in-depth since it is interpolated into the DDL below. +_validate_identifier(schema_name, "schema_name") + +# 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") + +# Keep the temp schema owned by the setup (deploy) principal so this job can create the results +# volume and manage grants on every run. Earlier versions transferred ownership to the app SP; we +# now keep it with the deployer and grant the SPs MANAGE instead (enough to drop temp views), which +# also lets the deployer CREATE VOLUME below. Reclaiming here is idempotent and fixes workspaces +# where a prior run already handed ownership to the app SP. +deploy_principal = _validate_principal(ws.current_user.me().user_name, "deploy principal") +spark.sql(f"ALTER SCHEMA `{catalog_name}`.`{schema_name}` OWNER TO `{deploy_principal}`") +logger.info(f"Schema owner set to deploy principal {deploy_principal}") + +# 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 reads through views and drops stale views (the in-app sweeper). MANAGE + # (not ownership) is enough to DROP views created by any user, so the deployer keeps ownership. + 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}`", + f"GRANT MANAGE ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{sp_principal}`", +] + +if runner_sp_configured: + # The runner job runs as a dedicated workspace SP. It must: use the catalog; read through the + # definer's-rights views (USE SCHEMA + SELECT); and DROP its own temp view at the end of each + # run (MANAGE — a non-owner with MANAGE can drop, same as the app SP / schema owner). + grants += [ + f"GRANT USE CATALOG ON CATALOG `{catalog_name}` TO `{runner_sp}`", + # save_checks / apply_checks_and_save_to_table write each caller's outputs to their own + # private per-user schema (dqx_mcp_), which the runner creates on demand and owns — + # so the runner SP needs CREATE SCHEMA on the catalog. + f"GRANT CREATE SCHEMA ON CATALOG `{catalog_name}` TO `{runner_sp}`", + f"GRANT USE SCHEMA ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{runner_sp}`", + f"GRANT SELECT ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{runner_sp}`", + f"GRANT MANAGE ON SCHEMA `{catalog_name}`.`{schema_name}` TO `{runner_sp}`", + ] + +for sql in grants: + logger.info(f"Executing: {sql}") + spark.sql(sql) + +# COMMAND ---------- + +# Results volume: the runner job (python_wheel_task) writes each operation's JSON result here, +# keyed by run id, and the app reads it back via the Files API (no SQL warehouse needed — the app +# has no warehouse of its own). Both SPs get READ+WRITE: the runner writes results, the app reads +# them and sweeps stale files. The setup principal owns the schema (above), so it can create this. +results_volume = "mcp_results" +_validate_identifier(results_volume, "results_volume") +volume_fqn = f"`{catalog_name}`.`{schema_name}`.`{results_volume}`" +spark.sql(f"CREATE VOLUME IF NOT EXISTS {volume_fqn}") +logger.info(f"Results volume {volume_fqn} ready") + +volume_grants = [f"GRANT READ VOLUME, WRITE VOLUME ON VOLUME {volume_fqn} TO `{sp_principal}`"] +if runner_sp_configured: + volume_grants.append(f"GRANT READ VOLUME, WRITE VOLUME ON VOLUME {volume_fqn} TO `{runner_sp}`") +for sql in volume_grants: + logger.info(f"Executing: {sql}") + spark.sql(sql) + +# COMMAND ---------- + +# Grant the app SP CAN_MANAGE_RUN on the runner job so the app can submit runs and poll their +# results. The app→job resource binding (databricks.yml apps.resources) did not reliably land this +# grant in the job ACL, so apply it explicitly here. Idempotent (update_permissions is additive). +if runner_job_id: + from databricks.sdk.service.jobs import JobAccessControlRequest, JobPermissionLevel + + ws.jobs.update_permissions( + job_id=runner_job_id, + access_control_list=[ + JobAccessControlRequest( + service_principal_name=sp_principal, permission_level=JobPermissionLevel.CAN_MANAGE_RUN + ) + ], + ) + logger.info(f"Granted app SP {sp_principal} CAN_MANAGE_RUN on runner job {runner_job_id}") +else: + logger.warning("runner_job_id not provided — skipping app-SP CAN_MANAGE_RUN grant on the runner job") + +# COMMAND ---------- + +# Artifacts volume: the bundle's workspace.artifact_path points here, so `bundle deploy` uploads +# the runner wheels into this volume. The serverless environment then installs them as the runner +# SP, which needs READ VOLUME. A UC volume grant is explicit and volume-wide, so — unlike the old +# workspace-folder approach — it doesn't depend on ACL inheritance from the deployer's home folder. +# The volume is created before deploy (scripts/ensure_artifacts_volume.sh); CREATE ... IF NOT EXISTS +# here is idempotent defense-in-depth (the deploy principal owns the schema, so it can create it). +artifacts_volume = "dqx_artifacts" +_validate_identifier(artifacts_volume, "artifacts_volume") +artifacts_volume_fqn = f"`{catalog_name}`.`{schema_name}`.`{artifacts_volume}`" +spark.sql(f"CREATE VOLUME IF NOT EXISTS {artifacts_volume_fqn}") +logger.info(f"Artifacts volume {artifacts_volume_fqn} ready") + +if runner_sp_configured: + sql = f"GRANT READ VOLUME ON VOLUME {artifacts_volume_fqn} TO `{runner_sp}`" + logger.info(f"Executing: {sql}") + spark.sql(sql) +else: + logger.warning("runner SP not provided — skipping runner-SP READ VOLUME grant on the artifacts volume") + +# COMMAND ---------- + +logger.info( + "Setup complete — schema owned by the deploy principal; app SP and runner SP granted MANAGE " + "(temp-view cleanup) and results-volume READ/WRITE; runner SP granted CREATE SCHEMA on the " + "catalog (per-user output schemas) and READ VOLUME on the artifacts volume; app SP granted " + "CAN_MANAGE_RUN on the runner job." +) diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 000000000..4911c4eaf --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,39 @@ +[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"] + +[tool.basedpyright] +# ``make mcp-check`` runs basedpyright over the synced runtime env (server deps only — no pytest, +# no pyspark). Exclude tests (already type-checked at pytest collection) and the runner sub-project +# (its pyspark/dqx deps aren't in this env — it builds and type-checks as its own wheel). Mirrors +# app/pyproject.toml: ``standard`` mode drops the strict-only ``recommended`` rules that fire on +# every Any-typed Databricks SDK / fastmcp response, keeping the check focused on real type errors +# and consistent across macOS/Linux (pyright's warning exit-code semantics differ by platform). +typeCheckingMode = "standard" +exclude = ["tests", "runner", "**/.venv", "**/__pycache__"] 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/runner/pyproject.toml b/mcp-server/runner/pyproject.toml new file mode 100644 index 000000000..3d617e682 --- /dev/null +++ b/mcp-server/runner/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "dqx-mcp-runner" +version = "0.1.0" +description = "Serverless wheel-task runner for the DQX MCP server — executes DQX operations on Databricks compute" +requires-python = ">=3.11" +# databricks-labs-dqx is provided by the sibling wheel listed in the runner job's environment spec +# (databricks.yml); it is declared here (unpinned) so the entry point resolves at build/lint time. +dependencies = [ + "databricks-labs-dqx", + "databricks-sdk>=0.60.0", +] + +[project.scripts] +dqx-mcp-runner = "dqx_mcp_runner.runner:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dqx_mcp_runner"] diff --git a/mcp-server/runner/src/dqx_mcp_runner/__init__.py b/mcp-server/runner/src/dqx_mcp_runner/__init__.py new file mode 100644 index 000000000..1c208dd81 --- /dev/null +++ b/mcp-server/runner/src/dqx_mcp_runner/__init__.py @@ -0,0 +1 @@ +"""DQX MCP runner package — serverless wheel-task entry point for DQX operations.""" diff --git a/mcp-server/runner/src/dqx_mcp_runner/naming.py b/mcp-server/runner/src/dqx_mcp_runner/naming.py new file mode 100644 index 000000000..0b844b86d --- /dev/null +++ b/mcp-server/runner/src/dqx_mcp_runner/naming.py @@ -0,0 +1,64 @@ +"""Pure naming + identifier-validation helpers for the DQX MCP runner. + +Intentionally dependency-free (stdlib only): no pyspark, no databricks-sdk, no dqx. Keeping these +pure functions in their own module lets them be unit-tested without a Spark session or the DQX +library, and separates naming/validation (a pure concern) from the Spark-coupled operations in +``runner.py``. +""" + +from __future__ import annotations + +import hashlib +import re + +# A bare SQL identifier (catalog/schema/table name part) and a UC principal (email / SP id / group). +# Must start with a letter or underscore: these parts are interpolated UNQUOTED into FQNs +# (OutputConfig.location, spark.table(...)), and an unquoted identifier that starts with a digit is +# a SQL parse error — so a leading-digit name is rejected up front rather than failing mid-write. +IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9._%+\-@]+$") + +# Per-user output schemas are named dqx_mcp__. Cap the local part so a +# long email can't produce an over-length schema name; the sha8 keeps it unique regardless. +_MAX_SCHEMA_USER_PART = 40 + + +def output_schema_for_user(email: str) -> str: + """Derive a collision-safe, per-user output schema name from a caller's email. + + Each caller's MCP outputs (saved rule sets, applied-check result tables) live in their own + schema ``dqx_mcp__`` — always a valid SQL identifier, deterministic + for a given email, and distinct per user (the sha8 suffix disambiguates callers whose local + parts sanitize to the same string). The runner SP owns the schema and grants only that caller + access, so users can neither see nor collide with each other's outputs. + + Raises: + ValueError: If *email* is empty (a caller identity is required to namespace outputs). + """ + normalized = (email or "").strip().lower() + if not normalized: + raise ValueError("A caller identity (email) is required to derive an output schema.") + local = normalized.split("@", 1)[0] + sanitized = re.sub(r"[^a-z0-9]+", "_", local).strip("_")[:_MAX_SCHEMA_USER_PART] + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8] + suffix = f"{sanitized}_{digest}" if sanitized else digest + return f"dqx_mcp_{suffix}" + + +def validate_identifier(value: str, label: str) -> str: + """Return *value* if it is a bare SQL identifier, else raise ``ValueError`` naming *label*.""" + if not IDENTIFIER_RE.match(str(value)): + raise ValueError(f"Invalid {label}: {value!r} (letters, digits, and underscores only).") + return value + + +def qualify_output(catalog: str, user_schema: str, name: str) -> str: + """Validate each part and return the unquoted 3-part FQN for a per-user output object. + + The name is caller-supplied, so it is re-validated here (defense-in-depth on top of the tool's + check) before it is interpolated into DQX config / ``spark.table`` calls. + """ + validate_identifier(catalog, "catalog") + validate_identifier(user_schema, "schema") + validate_identifier(name, "output name") + return f"{catalog}.{user_schema}.{name}" diff --git a/mcp-server/runner/src/dqx_mcp_runner/runner.py b/mcp-server/runner/src/dqx_mcp_runner/runner.py new file mode 100644 index 000000000..221a160a0 --- /dev/null +++ b/mcp-server/runner/src/dqx_mcp_runner/runner.py @@ -0,0 +1,479 @@ +"""DQX MCP runner — serverless wheel-task entry point. + +Runs a single DQX operation on Databricks compute and writes its JSON result to a file in the +MCP's results UC volume, keyed by the job run id. The MCP server (app) submits this as a +``python_wheel_task`` (see databricks.yml) and reads the result file back in ``get_run_result``. + +Why a wheel task (not a notebook): the job runs as a dedicated, least-privilege workspace service +principal (``run_as``), and the runner is a proper, unit-testable package rather than a notebook +full of ``dbutils`` — mirroring the DQX Studio task runner. The wheel is hosted in a UC volume +(``{catalog}.tmp.dqx_artifacts``, the bundle's ``workspace.artifact_path``); the setup job grants +the run_as SP ``READ VOLUME`` there so the serverless environment can install it (see +notebooks/setup.py). + +Result passing: notebook ``dbutils.notebook.exit`` is unavailable to wheel tasks, so results go to +``{catalog}.{schema}.mcp_results`` (a UC volume) as ``.json``. The app reads them via the +Files API (no SQL warehouse needed). On failure the entry point raises, so the job's result_state +becomes FAILED and the app surfaces the error from the run output. +""" + +from __future__ import annotations + +import argparse +import datetime +import io +import json +import logging +from decimal import Decimal +from typing import TYPE_CHECKING, Any + +from databricks.sdk import WorkspaceClient +from pyspark.sql import SparkSession + +if TYPE_CHECKING: + from databricks.labs.dqx.profiler.profiler import DQProfile + +from dqx_mcp_runner.naming import ( + IDENTIFIER_RE, + PRINCIPAL_RE, + output_schema_for_user, + qualify_output, + validate_identifier, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("dqx-mcp-runner") + +# Keep in sync with the app's read-side cap; notebook_output's 5MB limit is gone (files have no +# such limit) but very large payloads are still unhelpful to an agent, so cap defensively. +_MAX_RESULT_BYTES = 4_500_000 + + +def _make_json_safe(value: Any) -> Any: + """Recursively convert non-JSON-serializable values (Decimal, datetime).""" + 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 + + +def profile_table(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Profile a view and return summary stats + profiles.""" + from dataclasses import asdict + + from databricks.labs.dqx.config import InputConfig + from databricks.labs.dqx.profiler.profiler import DQProfiler + + view_name = params["view_name"] + columns = params.get("columns") + options = params.get("options") + + profiler = DQProfiler(workspace_client=ws, spark=spark) + summary_stats, profiles = profiler.profile_table(InputConfig(location=view_name), columns=columns, options=options) + + return { + "summary_stats": _make_json_safe(summary_stats), + "profiles": _make_json_safe([asdict(p) for p in profiles]), + } + + +def _to_dq_profile(p: dict[str, Any]) -> "DQProfile": + """Build a ``DQProfile`` from a caller-supplied profile dict, requiring ``name`` + ``column``. + + Raises a clear ``InvalidParameterError`` (not an opaque ``KeyError`` deep in the runner) when a + required key is missing, so a malformed profile from the client produces an actionable message. + """ + from databricks.labs.dqx.errors import InvalidParameterError + from databricks.labs.dqx.profiler.profiler import DQProfile + + for key in ("name", "column"): + if not p.get(key): + raise InvalidParameterError(f"Profile is missing required key '{key}' (got keys: {sorted(p)}).") + return DQProfile( + name=p["name"], + column=p["column"], + description=p.get("description"), + parameters=p.get("parameters"), + filter=p.get("filter"), + ) + + +def generate_rules(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Generate DQX rules from profiling output.""" + from databricks.labs.dqx.profiler.generator import DQGenerator + + profiles = params["profiles"] + criticality = params.get("criticality", "error") + + dq_profiles = [_to_dq_profile(p) for p in profiles] + + 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), + # Bounds/allowed-values in these rules come straight from the profiled data, so outliers or + # dirty values (e.g. a negative age) become the rule's limits. Surface this so the caller + # reviews them instead of enshrining bad data as the expectation. + "note": ( + "Generated bounds (min/max) and allowed values are derived from the profiled data, " + "including any outliers or dirty values. Review them against business expectations " + "(or a data contract) before using these rules in production." + ), + } + + +def load_checks(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Load DQX checks from a storage backend (table, UC volume, or workspace file).""" + from databricks.labs.dqx.checks_storage import ChecksStorageHandlerFactory + from databricks.labs.dqx.engine import DQEngine + + location = params["location"] + run_config_name = params.get("run_config_name", "default") + + 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} + + +def save_checks(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Save DQX checks to the caller's private, per-user MCP output schema (a Delta table). + + Outputs are namespaced per caller (``dqx_mcp_``, owned by the runner SP and granted only + to that caller) instead of a caller-supplied destination — so the SP always writes where it is + guaranteed to have permission, and there is no cross-user leak or name collision. + """ + from databricks.labs.dqx.checks_storage import ChecksStorageHandlerFactory + from databricks.labs.dqx.engine import DQEngine + from databricks.labs.dqx.errors import InvalidParameterError + + checks = params["checks"] + output_name = params["output_name"] + run_config_name = params.get("run_config_name", "default") + mode = params.get("mode", "append") + catalog = params["catalog"] + grant_to = params.get("grant_to") + if not grant_to: + raise InvalidParameterError("A caller identity is required to create your private output schema.") + + user_schema = output_schema_for_user(grant_to) + _ensure_output_schema(spark, catalog, user_schema, grant_to) + location = qualify_output(catalog, user_schema, output_name) + + engine = DQEngine(workspace_client=ws, spark=spark) + _handler, config = ChecksStorageHandlerFactory(ws, spark).create_for_location(location, run_config_name) + # Apply the write mode through the config's validator via replace() — NOT attribute assignment, + # which bypasses validation and silently no-ops on a config with no `mode`. The per-user output + # is always a table backend, which declares `mode`; fail clearly if that ever isn't the case. + if "mode" in type(config).model_fields: + config = config.replace(mode=mode) + else: + raise InvalidParameterError(f"The checks backend for '{location}' does not support a write mode.") + engine.save_checks(checks, config) + + granted_to = grant_to if _grant_table_access(spark, location, grant_to) else None + return { + "saved": True, + "count": len(checks), + "location": location, + "output_schema": f"{catalog}.{user_schema}", + "access_granted_to": granted_to, + } + + +def apply_checks_and_save_to_table(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Apply checks to a view and persist results to the caller's private, per-user output schema. + + The output (and optional quarantine) tables are created in ``dqx_mcp_`` — an SP-owned + schema granted only to the calling user — instead of a caller-supplied FQN, so the SP writes + only where it is guaranteed to have permission and users are isolated from each other. + """ + from databricks.labs.dqx.config import InputConfig, OutputConfig + from databricks.labs.dqx.engine import DQEngine + from databricks.labs.dqx.errors import InvalidParameterError + + view_name = params["view_name"] + checks = params["checks"] + output_name = params["output_name"] + quarantine_name = params.get("quarantine_name") + mode = params.get("mode", "append") + catalog = params["catalog"] + grant_to = params.get("grant_to") + if not grant_to: + raise InvalidParameterError("A caller identity is required to create your private output schema.") + + user_schema = output_schema_for_user(grant_to) + _ensure_output_schema(spark, catalog, user_schema, grant_to) + output_table = qualify_output(catalog, user_schema, output_name) + quarantine_table = qualify_output(catalog, user_schema, quarantine_name) if quarantine_name else None + + engine = DQEngine(workspace_client=ws, spark=spark) + engine.apply_checks_by_metadata_and_save_in_table( + input_config=InputConfig(location=view_name), + output_config=OutputConfig(location=output_table, mode=mode), + checks=checks, + quarantine_config=OutputConfig(location=quarantine_table, mode=mode) if quarantine_table else None, + ) + + created_tables = [tbl for tbl in (output_table, quarantine_table) if tbl] + granted_tables = [tbl for tbl in created_tables if _grant_table_access(spark, tbl, grant_to)] + + 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["output_schema"] = f"{catalog}.{user_schema}" + # Only claim access was granted if EVERY created table was granted — a partial grant (e.g. output + # succeeded, quarantine failed) must not report full access on a table the caller can't read. + result["access_granted_to"] = grant_to if granted_tables == created_tables else None + result["granted_tables"] = granted_tables + return result + + +def generate_rules_from_contract(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Generate DQX rules from an ODCS data contract file (deterministic schema/quality rules).""" + 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") + + # The runner env installs the bare DQX wheel + [datacontract], not the [llm] extra (dspy). The + # MCP tool is deterministic-only and never sets this; guard so a direct submission that sets it + # fails clearly instead of with an opaque ImportError deep inside DQX. + if process_text_rules: + raise ValueError( + "process_text_rules=True requires the DQX [llm] extra (dspy), which is not installed " + "in the runner environment. Use process_text_rules=False, or add the [llm] extra to the " + "runner environment in databricks.yml." + ) + + 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)} + + +def run_checks(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Run DQX checks against a view.""" + from databricks.labs.dqx.engine import DQEngine + + view_name = params["view_name"] + checks = params["checks"] + sample_size = params.get("sample_size", 50) + + 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) + + error_sample = [_make_json_safe(r.asDict(recursive=True)) for r in invalid_df.limit(sample_size).collect()] + return { + "total_rows": df.count(), + "valid_rows": valid_df.count(), + "invalid_rows": invalid_df.count(), + "error_sample": error_sample, + "rule_summary": _compute_rule_summary(invalid_df), + } + + +def validate_checks(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """Validate a list of DQX check definitions for correctness.""" + from databricks.labs.dqx.engine import DQEngine + + status = DQEngine.validate_checks(params["checks"]) + return {"valid": not status.has_errors, "errors": status.errors} + + +def list_available_checks(spark: SparkSession, ws: WorkspaceClient, params: dict) -> dict: + """List built-in DQX check functions with signatures and descriptions. + + An optional ``filter`` (case-insensitive substring) narrows the list by function name or + description, so a caller can search (e.g. ``filter='regex'``) instead of scanning every entry. + """ + import inspect + + from databricks.labs.dqx.checks_resolver import resolve_check_function + from databricks.labs.dqx.rule import CHECK_FUNC_REGISTRY + + name_filter = (params.get("filter") or "").strip().lower() + + 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 + if name_filter and name_filter not in name.lower() and name_filter not in (inspect.getdoc(func) or "").lower(): + 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 "" + checks.append( + { + "name": name, + "type": func_type, + "signature": f"{name}{sig}", + "description": doc.split("\n")[0] if doc else "", + "parameters": func_params, + } + ) + return {"checks": checks, "count": len(checks)} + + +def _compute_rule_summary(invalid_df) -> list: + """Aggregate per-rule error/warning counts.""" + import pyspark.sql.functions as F + + summary: dict = {} + 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")) + for row in exploded.groupBy("item.name").count().collect(): + rule_name = row["name"] or "unknown" + summary.setdefault(rule_name, {"error_count": 0, "warning_count": 0}) + summary[rule_name]["error_count" if col_name == "_errors" else "warning_count"] = row["count"] + return [{"rule_name": name, **counts} for name, counts in summary.items()] + + +def _ensure_output_schema(spark: SparkSession, catalog: str, user_schema: str, grant_to: str) -> None: + """Create the caller's SP-owned per-user output schema (idempotent) and grant them USE SCHEMA. + + The runner SP has CREATE SCHEMA on the catalog (see notebooks/setup.py), so it owns the schema + it creates and can freely write the caller's outputs — no write pre-check needed. The caller is + granted USE SCHEMA on ONLY their own schema, so users are isolated from each other's outputs. + """ + validate_identifier(catalog, "catalog") + validate_identifier(user_schema, "schema") + schema_fqn = f"`{catalog}`.`{user_schema}`" + spark.sql(f"CREATE SCHEMA IF NOT EXISTS {schema_fqn}") + if grant_to and PRINCIPAL_RE.match(str(grant_to)): + spark.sql(f"GRANT USE SCHEMA ON SCHEMA {schema_fqn} TO `{grant_to}`") + elif grant_to: + # The schema is created but we can't grant the caller — surface it (the result's + # access_granted_to will be null) so a locked-out caller isn't left with a silent no-op. + logger.warning(f"Not granting USE SCHEMA on {catalog}.{user_schema}: unexpected principal {grant_to!r}") + logger.info(f"Ensured per-user output schema {catalog}.{user_schema}") + + +def _grant_table_access(spark: SparkSession, table_name: str, principal: str) -> bool: + """Best-effort grant of ALL PRIVILEGES + MANAGE on a table to the calling user. Returns success.""" + if not principal or not table_name: + return False + if not PRINCIPAL_RE.match(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(IDENTIFIER_RE.match(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 + + +def _drop_view_safe(spark: SparkSession, view_name) -> None: + """Drop the OBO-created temp view for this run. Best-effort: logs, never raises.""" + if not view_name: + return + parts = str(view_name).split(".") + if len(parts) != 3 or not all(IDENTIFIER_RE.match(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) + + +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, +} + + +def _results_volume_path(results_volume: str, run_id: str) -> str: + """Full UC-volume file path for a run's result JSON.""" + return f"{results_volume.rstrip('/')}/{run_id}.json" + + +def _write_result(ws: WorkspaceClient, results_volume: str, run_id: str, result: dict) -> None: + """Write the result JSON to the results volume, keyed by run id. Caps oversized payloads.""" + payload = json.dumps(result) + if len(payload.encode("utf-8")) > _MAX_RESULT_BYTES: + payload = json.dumps({"error": "Output too large (>4.5MB). Try reducing sample_size.", "truncated": True}) + path = _results_volume_path(results_volume, run_id) + # files.upload expects a binary file-like object (it calls .seekable()), not raw bytes. + ws.files.upload(path, io.BytesIO(payload.encode("utf-8")), overwrite=True) + logger.info(f"Wrote result to {path} ({len(payload)} bytes)") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="DQX MCP runner") + parser.add_argument("--operation", required=True, help="DQX operation name") + parser.add_argument("--params", default="{}", help="JSON parameters for the operation") + parser.add_argument("--run-id", required=True, help="Job run id (result file key)") + parser.add_argument("--results-volume", required=True, help="UC volume path for result files") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + operation = args.operation + params = json.loads(args.params) + logger.info(f"Running operation: {operation}, params keys: {list(params.keys())}") + + spark = SparkSession.builder.getOrCreate() + ws = WorkspaceClient() + + if operation not in OPERATIONS: + raise ValueError(f"Unknown operation: {operation}. Valid: {list(OPERATIONS.keys())}") + + try: + result = OPERATIONS[operation](spark, ws, params) + # Echo the source table name so the client knows which table the result is for. + 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: + # Do NOT write a result file — raising makes the job result_state FAILED, and the app + # surfaces the error from the run output. Writing a success-shaped file would make the app + # report the failed op as completed. + logger.error(f"Operation '{operation}' failed: {e}", exc_info=True) + raise + finally: + # Always drop the run's temp view, on success or failure. + _drop_view_safe(spark, params.get("view_name")) + + _write_result(ws, args.results_volume, args.run_id, result) + logger.info(f"Operation '{operation}' complete.") + + +if __name__ == "__main__": + main() diff --git a/mcp-server/scripts/ci_deploy.sh b/mcp-server/scripts/ci_deploy.sh new file mode 100755 index 000000000..319c7a62d --- /dev/null +++ b/mcp-server/scripts/ci_deploy.sh @@ -0,0 +1,88 @@ +#!/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 via the env vars below so this never +# collides with a real deployment. The catalog is passed as a bundle var (no secret scope — the +# catalog name is not sensitive, and the app reads it from a plain config env value). +# +# 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) +# DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID application id of the workspace SP the runner job runs as. +# Optional: a real deploy sets this to a dedicated +# least-privilege SP (the deploying identity then needs the +# "User"/servicePrincipal.user role on it). When unset (CI), +# run_as defaults to the *deploying identity* itself — resolved +# via `databricks current-user me` — so run_as == creator and +# no cross-SP binding grant is needed. +# 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}" +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") + +# The runner job's run_as SP. Use the explicit override if given (a real deploy's dedicated SP); +# otherwise default to the *deploying identity* — whoever the CLI authenticates as — so run_as == +# the job's creator and Databricks needs no servicePrincipal.user grant to bind it (pinning a +# *different* SP requires that grant, which is what made a hard-coded runner SP 403 in CI). +RUNNER_SP="${DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID:-}" +if [ -z "$RUNNER_SP" ]; then + RUNNER_SP="$(databricks current-user me "${PROFILE_ARG[@]}" -o json \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("userName",""))')" + echo "runner_service_principal_id unset; defaulting run_as to the deploying identity: ${RUNNER_SP}" +fi +: "${RUNNER_SP:?could not resolve the runner run_as SP (set DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID, or ensure the deploy identity is resolvable via 'databricks current-user me')}" + +VARS=(--var "name_prefix=${NAME_PREFIX}" + --var "catalog_name=${DQX_MCP_TEST_CATALOG}" + --var "runner_service_principal_id=${RUNNER_SP}") + +cd "$(dirname "$0")/.." # mcp-server/ + +echo "::group::ensure artifacts volume (${DQX_MCP_TEST_CATALOG}.tmp.dqx_artifacts)" +# workspace.artifact_path is a UC volume; it must exist before `bundle deploy` uploads the wheels. +DQX_MCP_CATALOG="${DQX_MCP_TEST_CATALOG}" DATABRICKS_PROFILE="${DATABRICKS_PROFILE:-}" \ + ./scripts/ensure_artifacts_volume.sh +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::deploy + start app" +# Use `bundle run` (not raw `apps deploy --source-code-path`) so the app's runtime config — +# command + env, incl. DQX_CATALOG — comes from the bundle's inline `config:` block (there is no +# app.yaml). This matches `make mcp-deploy`; raw `apps deploy` reads app.yaml from the source and +# would fail with "No command to run" now that the config lives in databricks.yml. +databricks bundle run mcp-dqx -t "${BUNDLE_TARGET}" "${VARS[@]}" "${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}" +# The runner job's resolved run_as SP — tests grant it READ on the seed contract volume. +echo "DQX_MCP_RUNNER_SERVICE_PRINCIPAL=${RUNNER_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..1ea13398d --- /dev/null +++ b/mcp-server/scripts/ci_destroy.sh @@ -0,0 +1,23 @@ +#!/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}" +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:-}") + +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::" + +# The artifacts volume is created out-of-band (ensure_artifacts_volume.sh), so bundle destroy does +# not remove it. Drop it best-effort (ignore if absent / catalog unset). +if [ -n "${DQX_MCP_TEST_CATALOG:-}" ]; then + databricks volumes delete "${DQX_MCP_TEST_CATALOG}.tmp.dqx_artifacts" "${PROFILE_ARG[@]}" 2>/dev/null || true +fi diff --git a/mcp-server/scripts/ensure_artifacts_volume.sh b/mcp-server/scripts/ensure_artifacts_volume.sh new file mode 100755 index 000000000..b1372e04c --- /dev/null +++ b/mcp-server/scripts/ensure_artifacts_volume.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Create the UC volume that hosts the runner wheels, BEFORE `bundle deploy` uploads artifacts +# into it. The bundle's workspace.artifact_path is /Volumes//tmp/dqx_artifacts, and DAB +# uploads artifacts during deploy (before it would create any bundle-managed resources), so the +# volume must already exist. Idempotent / safe to re-run — creating an existing schema or volume +# is ignored. The runner job's run_as SP is later granted READ VOLUME here by the setup job. +# +# Env: +# DQX_MCP_CATALOG catalog holding the tmp schema + artifacts volume (required) +# DATABRICKS_PROFILE optional CLI profile (else relies on DATABRICKS_HOST/TOKEN) +set -euo pipefail + +: "${DQX_MCP_CATALOG:?DQX_MCP_CATALOG is not set (catalog that will host the dqx_artifacts volume)}" +SCHEMA="tmp" +VOLUME="dqx_artifacts" + +PROFILE_ARG=() +[ -n "${DATABRICKS_PROFILE:-}" ] && PROFILE_ARG=(--profile "$DATABRICKS_PROFILE") + +echo "Ensuring ${DQX_MCP_CATALOG}.${SCHEMA}.${VOLUME} exists (for runner wheels)..." +# Creating an existing schema/volume returns a non-zero "already exists" — tolerate it. Both +# commands create MANAGED objects owned by the calling (deploy) principal, so it can upload wheels. +databricks schemas create "${SCHEMA}" "${DQX_MCP_CATALOG}" "${PROFILE_ARG[@]}" 2>/dev/null || true +databricks volumes create "${DQX_MCP_CATALOG}" "${SCHEMA}" "${VOLUME}" MANAGED "${PROFILE_ARG[@]}" 2>/dev/null || true +echo "Artifacts volume /Volumes/${DQX_MCP_CATALOG}/${SCHEMA}/${VOLUME} ready." 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..699ea8973 --- /dev/null +++ b/mcp-server/server/app.py @@ -0,0 +1,107 @@ +""" +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 +import os + +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) + + +def _normalize_origin(value: str) -> str | None: + """Normalize a raw origin/host into a scheme-qualified origin, or None if blank.""" + value = value.strip().rstrip("/") + if not value: + return None + if not value.startswith(("http://", "https://")): + value = "https://" + value + return value + + +def get_allowed_cors_origins(databricks_host: str | None = None, extra_origins: str | None = None) -> list[str]: + """Build the exact list of browser origins allowed to make credentialed CORS calls. + + A credentialed wildcard over ``*.databricksapps.com`` is unsafe: that domain is + multi-tenant, so every other customer's app would be an allowed credentialed origin and + could replay JSON-RPC tool calls. Instead we allow only specific, single-tenant hosts: + + - the **workspace origin** (``DATABRICKS_HOST``) — where the Genie Code browser UI runs and + from which it issues the cross-origin preflight to this app; + - any operator-configured origins in ``DQX_MCP_EXTRA_CORS_ORIGINS`` (comma-separated), e.g. + a second workspace or a ``http://localhost`` dev origin. + + The app's own ``*.databricksapps.com`` URL is **same-origin** for its own page and needs no + CORS entry. Server-to-server callers (e.g. Claude) are not browsers and are unaffected. + """ + if databricks_host is None: + databricks_host = os.environ.get("DATABRICKS_HOST", "") + if extra_origins is None: + extra_origins = os.environ.get("DQX_MCP_EXTRA_CORS_ORIGINS", "") + + origins: list[str] = [] + for raw in [databricks_host, *extra_origins.split(",")]: + origin = _normalize_origin(raw) + if origin and origin not in origins: + origins.append(origin) + return origins + + +# Exact allow-list (not a regex/wildcard) so allow_credentials=True stays spec-compliant and +# scoped to this tenant. Starlette reflects the matched origin instead of the forbidden "*". +CORS_ALLOWED_ORIGINS = get_allowed_cors_origins() + +if CORS_ALLOWED_ORIGINS: + logger.info(f"CORS: allowing credentialed origins {CORS_ALLOWED_ORIGINS}") + combined_app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ALLOWED_ORIGINS, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + ) +else: + # No DATABRICKS_HOST (e.g. unconfigured local run). Skip CORS entirely rather than fall back + # to a permissive wildcard — browser callers will be blocked until an origin is configured. + logger.warning( + "CORS: no allowed origins resolved (DATABRICKS_HOST unset and DQX_MCP_EXTRA_CORS_ORIGINS " + "empty); browser-based callers will be blocked. Server-to-server callers are unaffected." + ) 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..6758e654c --- /dev/null +++ b/mcp-server/server/tools.py @@ -0,0 +1,612 @@ +import logging +import os +from typing import Any + +from databricks.sdk import WorkspaceClient + +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 _submit_or_drop_view( + operation: str, params: dict[str, Any], obo_ws: WorkspaceClient, view_fqn: str, warehouse_id: str +) -> int: + """Submit the runner job; if submission fails after the OBO temp view was created, drop the view. + + The runner drops its own view in a ``finally``, but only if it actually starts. A submission that + throws *after* ``create_temp_view`` (e.g. ``DQX_RUNNER_JOB_ID`` unset, a ``run_now`` RPC error, or + the throttled stale-view sweep raising) would otherwise leak the view in the shared ``tmp`` schema + until the TTL sweeper reaps it. Drop it here (as the caller/OBO client that created it) on failure. + """ + try: + return utils.submit_job_async(operation, params) + except Exception: + utils.drop_view(obo_ws, view_fqn, warehouse_id) + raise + + +def load_tools(mcp_server): + """Register all DQX MCP tools with the server. + + All DQX operations run in a wheel-task job (as a dedicated runner SP) — 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 = _submit_or_drop_view( + "profile_table", + {"view_name": view_fqn, "table_name": table_name, "columns": columns, "options": options}, + obo_ws, + view_fqn, + warehouse_id, + ) + + 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 | None = None, + contract_content: str | None = None, + contract_format: str = "odcs", + default_criticality: str = "error", + ): + """Generate DQX check definitions from a data contract (ODCS). + + Derives checks deterministically from the contract's schema and quality expectations — + a deterministic alternative to profiling when a data contract already exists. + + Provide the contract in ONE of two ways: + - contract_content: the inline contract text (recommended — no file access needed), or + - contract_file: a path to the contract (UC volume '/Volumes/...' or workspace '/...'). + + Either way, the app reads the contract as YOU (for a file, your UC/workspace permissions are + enforced) and stages a copy to a UC volume the runner can read — so a contract in your + Workspace files works even though the runner service principal has no access to them. + + Note: free-text expectations in the contract (DQX's LLM "text rules" path) are NOT + processed by this MCP server — that path needs the DQX [llm] extra (dspy) plus an LLM + endpoint, neither of which this deployment wires up. Only deterministic schema/quality + rules are generated. + + 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 (UC volume or workspace). Mutually exclusive + with contract_content. + contract_content: Inline contract text. Mutually exclusive with contract_file. + contract_format: Contract format (default 'odcs'). + default_criticality: Default criticality for generated rules: 'error' or 'warn'. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + if contract_file and contract_content: + raise ValueError("Provide only one of contract_file or contract_content, not both.") + + if contract_content is not None: + logger.info(f"Generating rules from inline contract ({len(contract_content)} chars)") + # Inline text — no file access needed. Stage it where the runner SP can read it. + staged_path = utils.stage_bytes_to_results_volume(contract_content.encode("utf-8"), suffix=".yaml") + elif contract_file is not None: + logger.info(f"Generating rules from contract: {utils.sanitize_for_log(contract_file)}") + # Governance: enforce the *caller's* read permission, then read the file AS the caller and + # stage a copy the runner SP can read. This closes the gap where the runner SP (which does + # the actual read) has no access to the caller's Workspace/Volume files. + obo_ws = utils.get_obo_client() + utils.verify_obo_read_access(obo_ws, contract_file) + content = utils.read_file_via_obo(obo_ws, contract_file) + _, ext = os.path.splitext(contract_file) + staged_path = utils.stage_bytes_to_results_volume(content, suffix=(ext or ".yaml")) + else: + raise ValueError("Provide either contract_file (a path) or contract_content (the inline contract text).") + + run_id = utils.submit_job_async( + "generate_rules_from_contract", + { + "contract_file": staged_path, + "contract_format": contract_format, + "default_criticality": default_criticality, + # process_text_rules is intentionally not exposed/sent: deterministic-only. The + # runner defaults it to False and guards against direct submissions that set it. + }, + ) + + 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)}") + obo_ws = utils.get_obo_client() + + # Governance: the runner loads as the SP, so enforce the caller's read permission first. + # For a table backend, route the read through a definer's-rights OBO view (same pattern as + # source-table reads) so the SP reads the checks table *as the caller*. For file backends, + # verify the caller's access to the path directly. + job_params: dict = {"run_config_name": run_config_name} + if utils.classify_location(location) == "table": + warehouse_id = utils.get_warehouse_id(obo_ws) + catalog, schema = _get_tmp_view_config() + view_fqn = utils.create_temp_view(obo_ws, location, catalog, schema, warehouse_id) + # Read through the view; let the runner drop it in its finally (stateless cleanup). + job_params["location"] = view_fqn + job_params["view_name"] = view_fqn + run_id = _submit_or_drop_view("load_checks", job_params, obo_ws, view_fqn, warehouse_id) + else: + utils.verify_obo_read_access(obo_ws, location) + job_params["location"] = location + run_id = utils.submit_job_async("load_checks", job_params) + + 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], + output_name: str, + run_config_name: str = "default", + mode: str = "append", + ): + """Save DQX checks to your private MCP output schema (a Delta table you can reuse). + + The checks are written to a Delta table named ``output_name`` inside your own private, + per-user MCP schema (``dqx_mcp_``) — you supply only the table name, not a + catalog/schema. The runner service principal creates the schema/table and grants YOU + access, so you can reuse the saved rule set from your own pipelines. Other users cannot + see your schema, and names can't collide across users. + + 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. + output_name: Bare table name for the saved rule set (letters, digits, underscores). + run_config_name: Run configuration name to tag the checks with. + 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"Saving {len(checks)} checks as: {utils.sanitize_for_log(output_name)}") + catalog, _schema = _get_tmp_view_config() + output_name = utils.validate_output_name(output_name) + mode = utils.validate_write_mode(mode) + + run_id = utils.submit_job_async( + "save_checks", + { + "checks": checks, + "output_name": output_name, + "run_config_name": run_config_name, + "mode": mode, + # The runner writes to (and grants the caller on) the caller's own per-user schema + # in this catalog — no caller-supplied destination, so no write pre-check needed. + "catalog": catalog, + "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 = _submit_or_drop_view( + "run_checks", + {"view_name": view_fqn, "table_name": table_name, "checks": checks, "sample_size": sample_size}, + obo_ws, + view_fqn, + warehouse_id, + ) + + 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_name: str, + quarantine_name: str | None = None, + mode: str = "append", + ): + """Apply DQX checks to a table and persist the results to your private MCP output schema. + + Unlike run_checks (which returns a sample), this operationalizes the checks: results are + written to Delta tables in your own private, per-user MCP schema (``dqx_mcp_``). You + supply only the output table name(s), not a catalog/schema. If ``quarantine_name`` is given, + valid rows go to the output table and invalid rows to the quarantine table; otherwise all + rows (with _errors/_warnings columns) go to the output table. The runner service principal + creates the tables and grants YOU access, so you can use them from your own pipelines; other + users cannot see your 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_name: Bare table name for the results (letters, digits, underscores). + quarantine_name: Optional bare table name 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_name)}" + ) + obo_ws = utils.get_obo_client() + warehouse_id = utils.get_warehouse_id(obo_ws) + catalog, schema = _get_tmp_view_config() + output_name = utils.validate_output_name(output_name) + quarantine_name = utils.validate_output_name(quarantine_name) if quarantine_name else None + mode = utils.validate_write_mode(mode) + + # The source read is governed by the caller's OBO definer's-rights view. The outputs go to + # the caller's own SP-owned per-user schema (created + granted by the runner), so there is + # no caller-supplied write destination and no write pre-check to get wrong. + view_fqn = utils.create_temp_view(obo_ws, table_name, catalog, schema, warehouse_id) + + run_id = _submit_or_drop_view( + "apply_checks_and_save_to_table", + { + "view_name": view_fqn, + "table_name": table_name, + "checks": checks, + "output_name": output_name, + "quarantine_name": quarantine_name, + "mode": mode, + "catalog": catalog, + "grant_to": utils.get_user_email(), + }, + obo_ws, + view_fqn, + warehouse_id, + ) + + 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', 'failed', or 'not_found' + - 'run_id': the run ID + - 'result': the operation result (only when status is 'completed') + - 'error': error message (when status is 'failed' or 'not_found') + + A 'not_found' status means the run_id is invalid, expired, or not from this server — + re-check the run_id returned by the original submit call rather than retrying blindly. + """ + logger.info(f"Checking run result: run_id={run_id}") + return utils.get_run_status(run_id) + + @mcp_server.tool + def list_available_checks(filter: str | None = None): + """List 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. + + Args: + filter: Optional case-insensitive substring to narrow the list by function name or + description (e.g. 'regex', 'null', 'range') — search instead of scanning them all. + + Returns a dict with: + - 'status': 'submitted' + - 'run_id': job run ID to pass to get_run_result + """ + logger.info(f"Listing available check functions (filter={utils.sanitize_for_log(filter) if filter else '-'})") + + run_id = utils.submit_job_async( + "list_available_checks", + {"filter": filter} if filter else {}, + ) + + 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 in your private per-user MCP schema " + "(dqx_mcp_) 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 in your private per-user MCP schema 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..aa9494fc6 --- /dev/null +++ b/mcp-server/server/utils.py @@ -0,0 +1,868 @@ +""" +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 +- App SP submits wheel-task jobs (run as a dedicated runner SP) 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, Message, 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 (creates temp views + enforces the caller's + UC permissions before the app SP submits the runner job) + - 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: Message) -> 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 + + +# A statement still PENDING/RUNNING after the execute wait window is polled to completion rather +# than treated as a failure (a cold-start warehouse or a very wide/partitioned table can exceed it). +_SQL_POLL_TIMEOUT_SECONDS = 120 +_SQL_POLL_INTERVAL_SECONDS = 2.0 + + +def _statement_state(result: Any) -> str: + """Normalized statement lifecycle state string (e.g. 'SUCCEEDED', 'RUNNING', 'FAILED').""" + state = result.status.state if result.status else None + if state is None: + return "UNKNOWN" + return str(getattr(state, "value", state)) + + +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", + ) + + # on_wait_timeout defaults to CONTINUE, so a slow statement can still be PENDING/RUNNING when the + # 30s wait elapses. Poll to completion instead of misreporting a healthy-but-slow query as failed. + deadline = time.monotonic() + _SQL_POLL_TIMEOUT_SECONDS + while _statement_state(result) in ("PENDING", "RUNNING") and result.statement_id: + if time.monotonic() > deadline: + raise RuntimeError(f"SQL query did not complete within {_SQL_POLL_TIMEOUT_SECONDS}s") + time.sleep(_SQL_POLL_INTERVAL_SECONDS) + result = ws.statement_execution.get_statement(result.statement_id) + + state = _statement_state(result) + if state != "SUCCEEDED": + # status can be None for some error/edge states (_statement_state handles that → "UNKNOWN"), + # so guard it here too rather than dereferencing .error and raising an opaque AttributeError. + err = result.status.error if result.status else None + error_msg = (getattr(err, "message", None) or str(err)) if err else state + raise RuntimeError(f"SQL query failed: {error_msg}") + + columns = [col.name for col in result.manifest.schema.columns] + rows: list[dict[str, Any]] = [] + + # The result set is chunked: execute_statement returns only the first chunk (result.result) and, + # for large results, a next_chunk_index pointing at the next one. Follow the chain to the end so + # callers (sweep_stale_views listing every temp view; get_table_schema on a wide table) see the + # full result rather than a silently truncated first page. Reading only the first chunk would + # leave orphan views un-swept and drop columns off a wide DESCRIBE. + chunk = result.result + while chunk is not None: + for row_data in chunk.data_array or []: + rows.append(dict(zip(columns, row_data))) + next_index = chunk.next_chunk_index + if next_index is None: + break + chunk = ws.statement_execution.get_statement_result_chunk_n(result.statement_id, next_index) + 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_output_name(name: str) -> str: + """Validate a caller-supplied output object name as a bare SQL identifier. + + Outputs (save_checks / apply_checks_and_save_to_table) are written to the caller's private, + per-user MCP schema, so the caller supplies only the table *name* — never a catalog/schema. + Reject anything that isn't a plain identifier (blocks FQNs, file paths, and SQL injection). + The name is interpolated UNQUOTED into the output FQN (OutputConfig.location, spark.table), so it + must also not start with a digit — an unquoted digit-leading identifier is a SQL parse error. + + Raises: + ValueError: If *name* is empty, starts with a digit, or contains characters outside + ``[A-Za-z0-9_]``. + """ + if not name or not _SAFE_IDENTIFIER_RE.match(name) or name[0].isdigit(): + raise ValueError( + f"Invalid output name '{name}'. Provide a bare table name that starts with a letter or " + f"underscore (letters, digits, underscores) — outputs are written to your private MCP " + f"schema, not an arbitrary catalog.schema.table." + ) + return name + + +_VALID_WRITE_MODES = ("append", "overwrite") + + +def validate_write_mode(mode: str) -> str: + """Validate a table write mode up front so an unsupported value fails clearly, not silently. + + Raises: + ValueError: If *mode* is not one of ``append`` / ``overwrite``. + """ + if mode not in _VALID_WRITE_MODES: + raise ValueError(f"Invalid mode '{mode}'. Must be one of {list(_VALID_WRITE_MODES)}.") + return mode + + +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) + + +# ── Caller-permission enforcement (OBO) ────────────────────────────── +# +# The runner job runs as a dedicated runner service principal. For *source-table* reads it reads +# through a definer's-rights view created with the caller's OBO token, so UC governance holds. +# +# Reads of caller-supplied files (load_checks file backend, generate_rules_from_contract) do NOT go +# through that view, so verify_obo_read_access confirms the caller can read the path — as the caller, +# via their OBO client — BEFORE the SP job is submitted. +# +# Writes need no such pre-check: save_checks / apply_checks_and_save_to_table no longer take a +# caller-supplied destination. Outputs go to the caller's own SP-owned per-user schema +# (dqx_mcp_, created + granted by the runner), so the SP always writes where it is guaranteed +# to have permission and users are isolated from each other. (An earlier verify_obo_write_access +# pre-check was removed: it checked the *caller's* perms while the *SP* did the write — false +# assurance — and classified UC errors by brittle string-matching.) + + +def classify_location(location: str) -> str: + """Classify a checks/contract location the same way DQX's storage factory does. + + Returns one of ``"table"`` (a ``catalog.schema.table`` name), ``"volume"`` (a + ``/Volumes/...`` path), or ``"workspace"`` (any other ``/...`` path). + """ + if location.startswith("/Volumes/"): + return "volume" + if location.startswith("/"): + return "workspace" + return "table" + + +def verify_obo_read_access(ws: Any, location: str) -> None: + """Verify the calling user can read *location* (file backends), as the caller via OBO. + + Tables are governed via a definer's-rights temp view (see :func:`create_temp_view`), so this + only covers UC-volume and workspace-file paths. Raises if the caller cannot access the path. + + Raises: + PermissionError: If the caller cannot read the path (or it does not exist). + """ + kind = classify_location(location) + try: + if kind == "volume": + ws.files.get_metadata(location) + elif kind == "workspace": + ws.workspace.get_status(location) + else: + # Tables are enforced separately via an OBO temp view; nothing to do here. + return + except Exception as e: + raise PermissionError( + f"You do not have access to {sanitize_for_log(location)} (or it does not exist): {e}" + ) from e + + +def read_file_via_obo(ws: Any, location: str) -> bytes: + """Read a file's raw bytes as the calling user (OBO). + + Supports the two file backends the tools accept: a UC-volume path (``/Volumes/...``) via the + Files API, and a workspace file (any other ``/...`` path) via the Workspace export API. Tables + are not files and are rejected. + + Raises: + ValueError: If *location* is a table name (not a file path). + """ + kind = classify_location(location) + if kind == "volume": + contents = ws.files.download(location).contents + return contents.read() if contents is not None else b"" + if kind == "workspace": + import base64 + + from databricks.sdk.service.workspace import ExportFormat + + exported = ws.workspace.export(location, format=ExportFormat.AUTO) + return base64.b64decode(exported.content) if exported.content else b"" + raise ValueError(f"{sanitize_for_log(location)} is a table name, not a file path.") + + +def stage_bytes_to_results_volume(content: bytes, suffix: str = "") -> str: + """Write *content* to the results volume via the app SP and return the ``/Volumes/...`` path. + + Used to stage a caller-supplied payload (e.g. a data contract) somewhere the **runner SP** can + read it: the runner SP has ``READ VOLUME`` on the results volume but no access to arbitrary + caller Workspace/Volume paths (the OBO read gap). The app SP has ``WRITE VOLUME`` here, so it + writes the staged copy and the runner reads it back. The stale-file sweeper reaps these by age, + same as result files. + """ + import io + + path = f"{_get_results_volume()}/staged_{uuid.uuid4().hex}{suffix}" + _get_sp_client().files.upload(path, io.BytesIO(content), overwrite=True) + logger.info(f"Staged {len(content)} bytes to {sanitize_for_log(path)}") + return path + + +# ── 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 sweep_stale_result_files(ws: Any, ttl_seconds: int = _VIEW_TTL_SECONDS) -> int: + """Delete result files older than *ttl_seconds* from the results volume. Best-effort. + + Backstop for result files whose caller never polled get_run_result. Uses each file's + last-modified time (the runner names files .json, which carries no timestamp). + Never raises — logs and moves on so cleanup can't break request handling. + """ + import time + + now = time.time() + dropped = 0 + try: + volume = _get_results_volume() + entries = ws.files.list_directory_contents(volume) + except Exception: + logger.warning("Result-file sweep: failed to list results volume", exc_info=True) + return 0 + + for entry in entries: + if getattr(entry, "is_directory", False): + continue + last_modified = getattr(entry, "last_modified", None) # epoch millis + age = now - (last_modified / 1000) if last_modified else 0 + if age > ttl_seconds: + try: + ws.files.delete(entry.path) + dropped += 1 + except Exception: + logger.warning(f"Result-file sweep: failed to delete {sanitize_for_log(entry.path)}", exc_info=True) + if dropped: + logger.info(f"Result-file sweep: deleted {dropped} stale result file(s)") + return dropped + + +def _maybe_sweep_stale_views() -> None: + """Run the stale-view and stale-result-file sweeps 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) + try: + sweep_stale_result_files(_get_sp_client()) + except Exception: + logger.warning("Result-file 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 _get_results_volume() -> str: + """UC-volume path where the runner writes result files: /Volumes///mcp_results. + + The runner (wheel task) writes ``.json`` here; the app reads it back via the Files API + (no SQL warehouse needed). Catalog/schema come from the same env the temp-view config uses. + """ + 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 f"/Volumes/{catalog}/{schema}/mcp_results" + + +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 passed to the runner as JSON. For table-backed operations, + include 'view_name' (dropped by the runner) and 'table_name' (echoed into the result). + + Returns: + The Databricks job run_id. + """ + # Opportunistically reap orphaned temp views and stale result files (throttled). + _maybe_sweep_stale_views() + + ws = _get_sp_client() + job_id = _get_runner_job_id() + + logger.info(f"Submitting async job {job_id}: operation={operation}") + + # job_parameters (not notebook_params): the runner is a python_wheel_task. results_volume tells + # the runner where to write .json; the app reads it back in get_run_status. + # requesting_user records the submitting caller on the run so get_run_status can authorize the + # reader (only the submitter may fetch the result — see the IDOR guard there). + wait = ws.jobs.run_now( + job_id=job_id, + job_parameters={ + "operation": operation, + "params": json.dumps(params), + "results_volume": _get_results_volume(), + "requesting_user": get_user_email() or "", + }, + ) + + run_id = wait.run_id + logger.info(f"Job submitted: run_id={run_id}") + return run_id + + +def _run_not_found(run_id: int) -> dict[str, Any]: + """Structured 'not_found' result for an invalid/expired/foreign run_id.""" + return { + "status": "not_found", + "run_id": run_id, + "error": ( + f"No run found for run_id={run_id}. It may be invalid, expired, or from a different " + f"job. Use the run_id returned by the submit call (profile_table, run_checks, etc.)." + ), + } + + +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', 'not_found') and optionally 'result'. + 'not_found' means the run_id is invalid, expired, or not from this MCP's runner job. + """ + from databricks.sdk.errors.base import DatabricksError + + ws = _get_sp_client() + + try: + run = ws.jobs.get_run(run_id) + except DatabricksError as e: + # An unknown/expired run_id surfaces as RESOURCE_DOES_NOT_EXIST (404) or, on some + # API/SDK versions, INVALID_PARAMETER_VALUE (400) "Run ... does not exist". Return a + # structured not_found so the calling agent fixes the run_id instead of treating an + # unstructured exception as a job failure. + error_code = getattr(e, "error_code", "") or "" + if error_code in ("RESOURCE_DOES_NOT_EXIST", "INVALID_PARAMETER_VALUE") or "does not exist" in str(e).lower(): + return _run_not_found(run_id) + raise + + # Guard against polling a run that belongs to some other job the SP can see — only the + # MCP runner job's runs are valid here. Best-effort: skip the check if the job id is unset. + try: + runner_job_id = _get_runner_job_id() + if run.job_id is not None and run.job_id != runner_job_id: + return _run_not_found(run_id) + except RuntimeError: + pass + + # Authorize the read: a run's result can contain the submitter's governed data (e.g. sampled + # rows from their source table), so only the user who submitted the run may read it. run_id is a + # guessable sequential integer and every user has CAN_MANAGE_RUN on the shared runner job, so we + # bind the run to its submitter via the requesting_user parameter recorded at submit time and + # reject a mismatch as not_found — without disclosing the result OR the run's existence/status. + submitter = (next((p.value for p in (run.job_parameters or []) if p.name == "requesting_user"), "") or "").strip() + caller = (get_user_email() or "").strip() + # Deny when either side is empty (an unowned run, or a caller with no identity) — never let an + # empty submitter match an empty caller, which would expose runs to an unauthenticated reader. + if not submitter or not caller or submitter.lower() != caller.lower(): + logger.warning(f"Denying run {run_id}: submitter/caller mismatch or missing identity") + return _run_not_found(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": + state_msg = (run.state.state_message if run.state else "") or "" + run_url = run.run_page_url or "" + # The job-level state_message is generic ("Workload failed, see run output for details"). + # Pull the failed task's actual error (e.g. a PERMISSION_DENIED on a table) so the calling + # agent gets an actionable reason instead of an opaque failure. + detail = state_msg + try: + task_run_id = run.tasks[0].run_id if run.tasks else run.run_id + if task_run_id is not None: + task_err = (ws.jobs.get_run_output(task_run_id).error or "").strip() + if task_err: + detail = f"{state_msg} {task_err}".strip() + except Exception: + logger.warning(f"Could not fetch task error for failed run {run_id}", exc_info=True) + return { + "status": "failed", + "run_id": run_id, + "error": f"Job failed: {detail or 'Unknown error'}. Debug at: {run_url}", + } + + # Read the result the runner (wheel task) wrote to the results volume, keyed by run id. + # (Wheel tasks have no notebook_output; the runner writes .json via the Files API.) + result_path = f"{_get_results_volume()}/{run_id}.json" + try: + download = ws.files.download(result_path) + if download.contents is None: + raise RuntimeError("result file download returned no contents") + content = download.contents.read() + result = json.loads(content) + # 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} + except Exception as e: + run_url = run.run_page_url or "" + return { + "status": "failed", + "run_id": run_id, + "error": f"Run succeeded but its result file could not be read ({sanitize_for_log(result_path)}): " + f"{e}. 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..72d7e33a5 --- /dev/null +++ b/mcp-server/tests/test_cors.py @@ -0,0 +1,128 @@ +"""Unit tests for the MCP server CORS policy. + +These tests drive the real Starlette CORSMiddleware using the same exact-origin +allow-list the production app builds, asserting observable CORS behaviour (which +origins are reflected) rather than the configuration value. The key property: the +credentialed allow-list is scoped to specific tenant hosts, NOT the multi-tenant +``*.databricksapps.com`` domain. +""" + +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 get_allowed_cors_origins + +# An example workspace origin (its DATABRICKS_HOST). Genie Code's browser UI runs at this origin +# and issues the cross-origin preflight to the app. (Illustrative host — not a real workspace.) +WORKSPACE_HOST = "https://adb-1234567890123456.7.azuredatabricks.net" +# A second, operator-configured origin (e.g. a local dev front-end). +EXTRA_ORIGIN = "http://localhost:3000" + +ALLOWED_ORIGINS = [WORKSPACE_HOST, EXTRA_ORIGIN] + +REJECTED_ORIGINS = [ + # The crux of Marcin's review: another tenant's app on the shared multi-tenant + # databricksapps.com domain must NOT be an allowed credentialed origin. + "https://someone-elses-app-9999.eastus.databricksapps.com", + # A different workspace. + "https://adb-0000000000000000.0.azuredatabricks.net", + "https://evil.example.com", + "https://databricks.com.evil.example.com", # suffix-spoof attempt + "http://adb-1234567890123456.7.azuredatabricks.net", # http downgrade of the allowed host +] + + +def _make_client() -> TestClient: + """Minimal app wrapped with the production exact-origin 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_origins=get_allowed_cors_origins(WORKSPACE_HOST, EXTRA_ORIGIN), + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + ) + return TestClient(app) + + +class TestAllowedOriginsBuilder: + def test_includes_workspace_host_and_extras(self): + origins = get_allowed_cors_origins(WORKSPACE_HOST, EXTRA_ORIGIN) + assert WORKSPACE_HOST in origins + assert EXTRA_ORIGIN in origins + + def test_bare_host_gets_https_scheme(self): + origins = get_allowed_cors_origins("adb-123.4.azuredatabricks.net", "") + assert origins == ["https://adb-123.4.azuredatabricks.net"] + + def test_trailing_slash_stripped_and_deduped(self): + origins = get_allowed_cors_origins(WORKSPACE_HOST + "/", WORKSPACE_HOST) + assert origins == [WORKSPACE_HOST] + + def test_empty_host_yields_no_origins(self): + assert get_allowed_cors_origins("", "") == [] + + def test_multiple_extra_origins_split_on_comma(self): + origins = get_allowed_cors_origins("", "https://a.example.com, https://b.example.com") + assert origins == ["https://a.example.com", "https://b.example.com"] + + def test_no_wildcard_databricksapps_origin(self): + # The allow-list must never contain a bare/wildcard databricksapps.com entry. + origins = get_allowed_cors_origins(WORKSPACE_HOST, EXTRA_ORIGIN) + assert all("databricksapps.com" not in o for o in origins) + + +class TestCorsPreflight: + @pytest.mark.parametrize("origin", ALLOWED_ORIGINS) + def test_allowed_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_disallowed_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": WORKSPACE_HOST}) + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == WORKSPACE_HOST + assert resp.headers["access-control-allow-credentials"] == "true" + + def test_other_tenant_app_not_reflected_on_actual_request(self): + client = _make_client() + resp = client.get("/", headers={"origin": "https://someone-elses-app-9999.eastus.databricksapps.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..f0344ddd9 --- /dev/null +++ b/mcp-server/tests/test_mcp_protocol.py @@ -0,0 +1,114 @@ +"""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, + patch.dict("os.environ", _ENV), + ): + async with Client(mcp_server) as client: + res = await client.call_tool( + "save_checks", {"checks": [{"check": "foo"}], "output_name": "my_checks", "mode": "overwrite"} + ) + mock_submit.assert_called_once_with( + "save_checks", + { + "checks": [{"check": "foo"}], + "output_name": "my_checks", + "run_config_name": "default", + "mode": "overwrite", + "catalog": "dqx_mcp", + # 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_runner_naming.py b/mcp-server/tests/test_runner_naming.py new file mode 100644 index 000000000..b2dbe619c --- /dev/null +++ b/mcp-server/tests/test_runner_naming.py @@ -0,0 +1,73 @@ +"""Unit tests for the runner's pure naming/validation helpers. + +``dqx_mcp_runner.naming`` is dependency-free (stdlib only), so it imports and runs in the +mcp-server test environment without pyspark or the DQX library. We add the runner package's ``src`` +to ``sys.path`` since it is a sibling sub-project, not an installed dependency of the server. +""" + +import pathlib +import sys + +import pytest + +_RUNNER_SRC = pathlib.Path(__file__).resolve().parent.parent / "runner" / "src" +if str(_RUNNER_SRC) not in sys.path: + sys.path.insert(0, str(_RUNNER_SRC)) + +from dqx_mcp_runner.naming import ( # noqa: E402 (path insert must precede import) + IDENTIFIER_RE, + output_schema_for_user, + qualify_output, + validate_identifier, +) + + +class TestOutputSchemaForUser: + def test_is_deterministic(self): + assert output_schema_for_user("alice@databricks.com") == output_schema_for_user("alice@databricks.com") + + def test_is_a_valid_identifier(self): + for email in ("alice@databricks.com", "a.b+c@x.co", "UPPER@X.COM", "weird!!name@x.com"): + schema = output_schema_for_user(email) + assert IDENTIFIER_RE.match(schema), f"{schema!r} is not a bare identifier" + assert schema.startswith("dqx_mcp_") + + def test_distinct_per_user(self): + assert output_schema_for_user("alice@databricks.com") != output_schema_for_user("bob@databricks.com") + + def test_case_insensitive(self): + # Emails are case-insensitive in practice; the schema must not differ by case. + assert output_schema_for_user("Alice@Databricks.com") == output_schema_for_user("alice@databricks.com") + + def test_distinguishes_same_local_part_across_domains(self): + # Same local part, different domain → the sha8 (over the full email) keeps them distinct. + assert output_schema_for_user("me@a.com") != output_schema_for_user("me@b.com") + + @pytest.mark.parametrize("empty", ["", " ", None]) + def test_empty_email_rejected(self, empty): + with pytest.raises(ValueError, match="caller identity"): + output_schema_for_user(empty) # type: ignore[arg-type] + + +class TestQualifyOutput: + def test_returns_unquoted_fqn(self): + assert qualify_output("cat", "dqx_mcp_alice_abcd1234", "orders_out") == "cat.dqx_mcp_alice_abcd1234.orders_out" + + @pytest.mark.parametrize( + "name", ["catalog.schema.table", "/Volumes/x", "has space", "drop`table", "", "2024_out", "9x"] + ) + def test_rejects_non_identifier_name(self, name): + # Leading-digit names are rejected too — the FQN is interpolated unquoted, where a + # digit-leading identifier part is a SQL parse error. + with pytest.raises(ValueError, match="Invalid output name"): + qualify_output("cat", "sch", name) + + +class TestValidateIdentifier: + def test_accepts_and_returns(self): + assert validate_identifier("abc_123", "thing") == "abc_123" + + @pytest.mark.parametrize("bad", ["a.b", "1abc", "9", "has space"]) + def test_rejects(self, bad): + with pytest.raises(ValueError, match="Invalid thing"): + validate_identifier(bad, "thing") diff --git a/mcp-server/tests/test_tools.py b/mcp-server/tests/test_tools.py new file mode 100644 index 000000000..ca7ca1a1d --- /dev/null +++ b/mcp-server/tests/test_tools.py @@ -0,0 +1,439 @@ +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 + + def test_drops_temp_view_if_submit_fails(self): + """If submission throws after the OBO view is created, the view is dropped (no leak).""" + import pytest + + 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_leak"), + patch("server.tools.utils.submit_job_async", side_effect=RuntimeError("submit boom")), + patch("server.tools.utils.drop_view") as mock_drop, + patch.dict("os.environ", _ENV), + ): + with pytest.raises(RuntimeError, match="submit boom"): + tools["profile_table"]("catalog.schema.table") + + # The exact view that was created is dropped (as the OBO client) on the submit failure. + mock_drop.assert_called_once() + assert mock_drop.call_args[0][1] == "dqx_mcp.tmp.v_leak" + + +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_list_available_checks_forwards_filter(self): + tools = _register_tools() + + with patch("server.tools.utils.submit_job_async", return_value=9) as mock_submit: + tools["list_available_checks"](filter="regex") + + # A filter is forwarded to the runner so it can narrow the (large) registry server-side. + mock_submit.assert_called_once_with("list_available_checks", {"filter": "regex"}) + + 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_file_is_staged(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.verify_obo_read_access") as mock_verify, + patch("server.tools.utils.read_file_via_obo", return_value=b"contract-bytes") as mock_read, + patch( + "server.tools.utils.stage_bytes_to_results_volume", + return_value="/Volumes/c/tmp/mcp_results/staged_x.yml", + ) as mock_stage, + patch("server.tools.utils.submit_job_async", return_value=21) as mock_submit, + ): + result = tools["generate_rules_from_contract"](contract_file="/Volumes/c/s/v/contract.yml") + + # The caller's read access is enforced, the file is read AS the caller, and a copy is staged + # to a runner-readable volume — the staged path (not the caller's path) goes to the runner. + assert mock_verify.call_args[0][1] == "/Volumes/c/s/v/contract.yml" + assert mock_read.call_args[0][1] == "/Volumes/c/s/v/contract.yml" + assert mock_stage.call_args[0][0] == b"contract-bytes" + # Deterministic-only: process_text_rules is not exposed and not sent to the runner. + mock_submit.assert_called_once_with( + "generate_rules_from_contract", + { + "contract_file": "/Volumes/c/tmp/mcp_results/staged_x.yml", + "contract_format": "odcs", + "default_criticality": "error", + }, + ) + assert result["run_id"] == 21 + + def test_generate_rules_from_contract_inline_content(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client") as mock_obo, + patch( + "server.tools.utils.stage_bytes_to_results_volume", + return_value="/Volumes/c/tmp/mcp_results/staged_y.yaml", + ) as mock_stage, + patch("server.tools.utils.submit_job_async", return_value=22) as mock_submit, + ): + result = tools["generate_rules_from_contract"](contract_content="kind: DataContract") + + # Inline content needs no file access at all — no OBO client, just stage + submit. + mock_obo.assert_not_called() + assert mock_stage.call_args[0][0] == b"kind: DataContract" + assert mock_submit.call_args[0][1]["contract_file"] == "/Volumes/c/tmp/mcp_results/staged_y.yaml" + assert result["run_id"] == 22 + + def test_generate_rules_from_contract_requires_exactly_one_source(self): + import pytest + + tools = _register_tools() + with pytest.raises(ValueError, match="Provide either"): + tools["generate_rules_from_contract"]() + with pytest.raises(ValueError, match="only one"): + tools["generate_rules_from_contract"](contract_file="/Volumes/x", contract_content="y") + + def test_generate_rules_from_contract_has_no_text_rules_param(self): + """The deterministic-only tool must not expose a process_text_rules knob.""" + import inspect + + tools = _register_tools() + params = inspect.signature(tools["generate_rules_from_contract"]).parameters + assert "process_text_rules" not in params + + def test_load_checks_table_backend_routes_through_obo_view(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_chk") as mock_create, + patch("server.tools.utils.submit_job_async", return_value=22) as mock_submit, + patch.dict("os.environ", _ENV), + ): + result = tools["load_checks"]("catalog.schema.checks") + + # Table-backed reads go through a definer's-rights OBO view; the runner drops it. + mock_create.assert_called_once() + mock_submit.assert_called_once_with( + "load_checks", + { + "run_config_name": "default", + "location": "dqx_mcp.tmp.v_chk", + "view_name": "dqx_mcp.tmp.v_chk", + }, + ) + assert result["run_id"] == 22 + + def test_load_checks_file_backend_verifies_read_access(self): + tools = _register_tools() + + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.verify_obo_read_access") as mock_verify, + patch("server.tools.utils.create_temp_view") as mock_create, + patch("server.tools.utils.submit_job_async", return_value=22) as mock_submit, + ): + result = tools["load_checks"]("/Volumes/c/s/v/checks.yml") + + # No view for file backends — read access is verified directly, location passes through. + mock_create.assert_not_called() + assert mock_verify.call_args[0][1] == "/Volumes/c/s/v/checks.yml" + mock_submit.assert_called_once_with( + "load_checks", {"run_config_name": "default", "location": "/Volumes/c/s/v/checks.yml"} + ) + 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"), + patch.dict("os.environ", _ENV), + ): + result = tools["save_checks"]([{"check": "foo"}], "my_checks", mode="overwrite") + + # Outputs go to the caller's per-user schema in the configured catalog — no write pre-check, + # only the bare output name + catalog + caller travel to the runner. + mock_submit.assert_called_once_with( + "save_checks", + { + "checks": [{"check": "foo"}], + "output_name": "my_checks", + "run_config_name": "default", + "mode": "overwrite", + "catalog": "dqx_mcp", + "grant_to": "user@example.com", + }, + ) + assert result["run_id"] == 23 + + def test_save_checks_rejects_fqn_output_name(self): + """A dotted / path-like output name is rejected — outputs are bare names in the user schema.""" + import pytest + + tools = _register_tools() + with patch.dict("os.environ", _ENV): + with pytest.raises(ValueError, match="Invalid output name"): + tools["save_checks"]([{"check": "foo"}], "catalog.schema.table") + + def test_save_checks_rejects_bad_mode(self): + import pytest + + tools = _register_tools() + with patch.dict("os.environ", _ENV): + with pytest.raises(ValueError, match="Invalid mode"): + tools["save_checks"]([{"check": "foo"}], "my_checks", mode="upsert") + + +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"}], + "orders_out", + quarantine_name="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"}] + # Bare output names + the configured catalog travel to the runner, which resolves them to + # the caller's per-user schema. No caller-supplied FQN, no write pre-check. + assert job_params["output_name"] == "orders_out" + assert job_params["quarantine_name"] == "orders_quarantine" + assert job_params["catalog"] == "dqx_mcp" + 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 + + def test_rejects_fqn_output_name(self): + import pytest + + tools = _register_tools() + with ( + patch("server.tools.utils.get_obo_client"), + patch("server.tools.utils.get_warehouse_id", return_value="wh123"), + patch.dict("os.environ", _ENV), + ): + with pytest.raises(ValueError, match="Invalid output name"): + tools["apply_checks_and_save_to_table"]( + "catalog.schema.orders", [{"check": "foo"}], "catalog.schema.orders_out" + ) diff --git a/mcp-server/tests/test_utils.py b/mcp-server/tests/test_utils.py new file mode 100644 index 000000000..6e5fc4662 --- /dev/null +++ b/mcp-server/tests/test_utils.py @@ -0,0 +1,919 @@ +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 ( + JobParameter, + Run, + RunOutput, + RunState, + RunLifeCycleState, + RunResultState, +) + +_JOB_ID_ENV = {"DQX_RUNNER_JOB_ID": "42", "DQX_CATALOG": "cat", "DQX_TMP_SCHEMA": "tmp"} + + +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] + # Recorded submitter matches the caller the TestGetRunStatus autouse fixture sets. + run.job_parameters = [JobParameter(name="requesting_user", value="user@example.com")] + 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"]] + mock_result.result.next_chunk_index = None # single chunk + 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"}] + # A single-chunk result must not fetch further chunks. + ws.statement_execution.get_statement_result_chunk_n.assert_not_called() + + def test_paginates_across_chunks(self): + """A multi-chunk result set is fully read by following next_chunk_index (not truncated).""" + from server.utils import execute_sql + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status.state = "SUCCEEDED" + mock_result.statement_id = "stmt-1" + col = MagicMock() + col.name = "viewName" + mock_result.manifest.schema.columns = [col] + # First chunk (inline in execute_statement) points at chunk 1. + mock_result.result.data_array = [["v_1"], ["v_2"]] + mock_result.result.next_chunk_index = 1 + ws.statement_execution.execute_statement.return_value = mock_result + + # Second (final) chunk fetched via get_statement_result_chunk_n; no further chunk. + chunk1 = MagicMock() + chunk1.data_array = [["v_3"]] + chunk1.next_chunk_index = None + ws.statement_execution.get_statement_result_chunk_n.return_value = chunk1 + + rows = execute_sql(ws, "SHOW VIEWS IN c.s", warehouse_id="wh123") + assert rows == [{"viewName": "v_1"}, {"viewName": "v_2"}, {"viewName": "v_3"}] + ws.statement_execution.get_statement_result_chunk_n.assert_called_once_with("stmt-1", 1) + + 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 == [] + + def test_none_status_raises_clean_error_not_attributeerror(self): + """A result with status=None must raise a clean RuntimeError, not AttributeError on .error.""" + from server.utils import execute_sql + + ws = create_autospec(WorkspaceClient) + mock_result = MagicMock() + mock_result.status = None # some error/edge states return no status + mock_result.statement_id = "s1" + ws.statement_execution.execute_statement.return_value = mock_result + + with pytest.raises(RuntimeError, match="SQL query failed: UNKNOWN"): + execute_sql(ws, "SELECT 1", warehouse_id="wh123") + + +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_params_and_results_volume_as_job_parameters(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"]}) + + # Wheel task → job_parameters (not notebook_params), incl. the results volume path. + job_parameters = ws.jobs.run_now.call_args.kwargs["job_parameters"] + assert job_parameters["operation"] == "profile_table" + assert json.loads(job_parameters["params"]) == {"view_name": "c.s.v_abc", "columns": ["a"]} + assert job_parameters["results_volume"] == "/Volumes/cat/tmp/mcp_results" + # requesting_user is recorded on the run so get_run_status can authorize the reader (IDOR guard). + assert "requesting_user" in job_parameters + + def test_stamps_requesting_user_from_obo_email(self): + from server.utils import submit_job_async, _user_email_var + + ws = create_autospec(WorkspaceClient) + ws.jobs.run_now.return_value = MagicMock(run_id=123) + tok = _user_email_var.set("alice@example.com") + try: + 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"}) + finally: + _user_email_var.reset(tok) + assert ws.jobs.run_now.call_args.kwargs["job_parameters"]["requesting_user"] == "alice@example.com" + + 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: + @pytest.fixture(autouse=True) + def _caller(self): + """Default caller identity for these tests — matches _make_terminated_run's requesting_user + so the IDOR ownership guard passes on the happy paths.""" + from server.utils import _user_email_var + + tok = _user_email_var.set("user@example.com") + yield + _user_email_var.reset(tok) + + def test_completed_reads_result_file_from_volume(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.SUCCESS) + # table_name is echoed by the runner into its result; get_run_status returns it as-is. + ws.files.download.return_value.contents.read.return_value = json.dumps( + {"profiles": [], "table_name": "c.s.t"} + ).encode() + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", {"DQX_CATALOG": "cat", "DQX_TMP_SCHEMA": "tmp"}), + ): + 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" + # Result is read from the results volume, keyed by run id. + assert ws.files.download.call_args[0][0] == "/Volumes/cat/tmp/mcp_results/123.json" + + def test_completed_but_missing_result_file_is_failed(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.SUCCESS) + ws.files.download.side_effect = Exception("not found") + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", {"DQX_CATALOG": "cat", "DQX_TMP_SCHEMA": "tmp"}), + ): + result = get_run_status(123) + + assert result["status"] == "failed" + assert "result file could not be read" in result["error"] + + def test_failed_run_surfaces_task_detail(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.return_value = _make_terminated_run(RunResultState.FAILED) + out = MagicMock(spec=RunOutput) + out.error = "PERMISSION_DENIED: User does not have SELECT on Table foo. SQLSTATE: 42501" + ws.jobs.get_run_output.return_value = out + + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(123) + + assert result["status"] == "failed" + assert "boom" in result["error"] # job-level state_message + assert "PERMISSION_DENIED" in result["error"] # task-level detail now surfaced + ws.jobs.get_run_output.assert_called_once_with(456) + + def test_not_found_when_run_does_not_exist(self): + from server.utils import get_run_status + from databricks.sdk.errors import ResourceDoesNotExist + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.side_effect = ResourceDoesNotExist("Run 999 does not exist.") + + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(999) + + assert result["status"] == "not_found" + assert result["run_id"] == 999 + assert "999" in result["error"] + ws.jobs.get_run_output.assert_not_called() + + def test_unexpected_databricks_error_is_reraised(self): + from server.utils import get_run_status + from databricks.sdk.errors import PermissionDenied + + ws = create_autospec(WorkspaceClient) + ws.jobs.get_run.side_effect = PermissionDenied("nope") + + with patch("server.utils._get_sp_client", return_value=ws): + with pytest.raises(PermissionDenied): + get_run_status(123) + + def test_not_found_when_run_belongs_to_other_job(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) + run.job_id = 999 # not the runner job (DQX_RUNNER_JOB_ID=42) + ws.jobs.get_run.return_value = run + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + + assert result["status"] == "not_found" + ws.jobs.get_run_output.assert_not_called() + + def test_matching_job_id_is_accepted(self): + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) + run.job_id = 42 # matches DQX_RUNNER_JOB_ID + ws.jobs.get_run.return_value = run + ws.files.download.return_value.contents.read.return_value = json.dumps({"ok": True}).encode() + + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + + assert result["status"] == "completed" + + def test_denies_run_submitted_by_another_user(self): + """IDOR guard: a caller may not read a run someone else submitted (not_found, no disclosure).""" + from server.utils import get_run_status, _user_email_var + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) + run.job_id = 42 + run.job_parameters = [JobParameter(name="requesting_user", value="alice@example.com")] + ws.jobs.get_run.return_value = run + + tok = _user_email_var.set("bob@example.com") # a different caller + try: + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + finally: + _user_email_var.reset(tok) + + assert result["status"] == "not_found" + # The result file is never even read for a run the caller doesn't own. + ws.files.download.assert_not_called() + + def test_allows_run_submitted_by_same_user(self): + from server.utils import get_run_status, _user_email_var + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) + run.job_id = 42 + run.job_parameters = [JobParameter(name="requesting_user", value="alice@example.com")] + ws.jobs.get_run.return_value = run + ws.files.download.return_value.contents.read.return_value = json.dumps({"ok": True}).encode() + + tok = _user_email_var.set("Alice@example.com") # same user (case-insensitive) + try: + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + finally: + _user_email_var.reset(tok) + + assert result["status"] == "completed" + + def test_denies_when_caller_has_no_identity(self): + """A caller with no OBO identity must not read a run, even one with a recorded submitter.""" + from server.utils import get_run_status, _user_email_var + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) # requesting_user=user@example.com + run.job_id = 42 + ws.jobs.get_run.return_value = run + + tok = _user_email_var.set(None) # no caller identity + try: + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + finally: + _user_email_var.reset(tok) + + assert result["status"] == "not_found" + ws.files.download.assert_not_called() + + def test_denies_when_run_has_no_submitter(self): + """A run with no recorded submitter is unowned and must not be readable by any caller.""" + from server.utils import get_run_status + + ws = create_autospec(WorkspaceClient) + run = _make_terminated_run(RunResultState.SUCCESS) + run.job_id = 42 + run.job_parameters = [] # no requesting_user recorded (older run / non-OBO submit) + ws.jobs.get_run.return_value = run + + # autouse caller is user@example.com, but the empty submitter must still be denied + with ( + patch("server.utils._get_sp_client", return_value=ws), + patch.dict("os.environ", _JOB_ID_ENV), + ): + result = get_run_status(123) + + assert result["status"] == "not_found" + ws.files.download.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, _user_email_var + + ws = create_autospec(WorkspaceClient) + run = MagicMock(spec=Run) + run.state = MagicMock(spec=RunState) + run.state.life_cycle_state = RunLifeCycleState.RUNNING + run.job_parameters = [JobParameter(name="requesting_user", value="user@example.com")] + ws.jobs.get_run.return_value = run + + tok = _user_email_var.set("user@example.com") # caller owns the run (IDOR guard passes) + try: + with patch("server.utils._get_sp_client", return_value=ws): + result = get_run_status(123) + finally: + _user_email_var.reset(tok) + + 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"] + + +class TestClassifyLocation: + def test_table(self): + from server.utils import classify_location + + assert classify_location("catalog.schema.table") == "table" + + def test_volume(self): + from server.utils import classify_location + + assert classify_location("/Volumes/c/s/v/checks.yml") == "volume" + + def test_workspace(self): + from server.utils import classify_location + + assert classify_location("/Workspace/Users/me/checks.yml") == "workspace" + + +class TestVerifyOboReadAccess: + def test_volume_calls_files_get_metadata(self): + from server.utils import verify_obo_read_access + + ws = create_autospec(WorkspaceClient) + verify_obo_read_access(ws, "/Volumes/c/s/v/f.yml") + ws.files.get_metadata.assert_called_once_with("/Volumes/c/s/v/f.yml") + + def test_workspace_calls_get_status(self): + from server.utils import verify_obo_read_access + + ws = create_autospec(WorkspaceClient) + verify_obo_read_access(ws, "/Workspace/x.yml") + ws.workspace.get_status.assert_called_once_with("/Workspace/x.yml") + + def test_table_is_noop(self): + from server.utils import verify_obo_read_access + + ws = create_autospec(WorkspaceClient) + verify_obo_read_access(ws, "c.s.t") + ws.files.get_metadata.assert_not_called() + ws.workspace.get_status.assert_not_called() + + def test_raises_permission_error_on_failure(self): + from server.utils import verify_obo_read_access + + ws = create_autospec(WorkspaceClient) + ws.files.get_metadata.side_effect = Exception("denied") + with pytest.raises(PermissionError): + verify_obo_read_access(ws, "/Volumes/c/s/v/f.yml") + + +class TestValidateOutputName: + """Outputs go to the caller's per-user schema, so the tool takes a bare name — not an FQN.""" + + @pytest.mark.parametrize("name", ["my_checks", "orders_out", "T1", "a_b_c123"]) + def test_accepts_bare_identifiers(self, name: str): + from server.utils import validate_output_name + + assert validate_output_name(name) == name + + @pytest.mark.parametrize( + "name", + ["catalog.schema.table", "/Volumes/c/s/v/x.yml", "has space", "drop`table", "", "a-b", "2024_results", "1t"], + ) + def test_rejects_non_identifiers(self, name: str): + from server.utils import validate_output_name + + # Includes leading-digit names: the FQN is used UNQUOTED, so a digit-leading table part is a + # SQL parse error — must be rejected up front. + with pytest.raises(ValueError, match="Invalid output name"): + validate_output_name(name) + + +class TestValidateWriteMode: + @pytest.mark.parametrize("mode", ["append", "overwrite"]) + def test_accepts_supported_modes(self, mode: str): + from server.utils import validate_write_mode + + assert validate_write_mode(mode) == mode + + @pytest.mark.parametrize("mode", ["upsert", "merge", "APPEND", ""]) + def test_rejects_unsupported_modes(self, mode: str): + from server.utils import validate_write_mode + + with pytest.raises(ValueError, match="Invalid mode"): + validate_write_mode(mode) + + +class TestReadFileViaObo: + def test_volume_uses_files_download(self): + from server.utils import read_file_via_obo + + ws = create_autospec(WorkspaceClient) + ws.files.download.return_value.contents.read.return_value = b"data" + assert read_file_via_obo(ws, "/Volumes/c/s/v/f.yml") == b"data" + ws.files.download.assert_called_once_with("/Volumes/c/s/v/f.yml") + + def test_workspace_uses_export_and_b64decode(self): + import base64 + + from server.utils import read_file_via_obo + + ws = create_autospec(WorkspaceClient) + ws.workspace.export.return_value.content = base64.b64encode(b"hi there").decode() + assert read_file_via_obo(ws, "/Workspace/Users/me/f.yml") == b"hi there" + + def test_table_is_rejected(self): + from server.utils import read_file_via_obo + + ws = create_autospec(WorkspaceClient) + with pytest.raises(ValueError, match="table name"): + read_file_via_obo(ws, "c.s.t") + + +class TestStageBytesToResultsVolume: + def test_uploads_via_sp_and_returns_volume_path(self): + from server.utils import stage_bytes_to_results_volume + + sp = create_autospec(WorkspaceClient) + with ( + patch("server.utils._get_results_volume", return_value="/Volumes/c/tmp/mcp_results"), + patch("server.utils._get_sp_client", return_value=sp), + ): + path = stage_bytes_to_results_volume(b"payload", suffix=".yaml") + + assert path.startswith("/Volumes/c/tmp/mcp_results/staged_") + assert path.endswith(".yaml") + # Uploaded (as the app SP) to exactly the returned path. + assert sp.files.upload.call_args[0][0] == path 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 01a92a26e..213dc110c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -191,18 +191,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) @@ -218,7 +218,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] @@ -231,7 +231,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"] @@ -258,7 +258,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..96da06952 --- /dev/null +++ b/tests/integration_mcp/conftest.py @@ -0,0 +1,496 @@ +"""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 base64 +import contextlib +import io +import json +import os +import re +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 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 + +# The runner job's run_as SP — an *explicit override* only. For a real deploy, set this to a +# dedicated least-privilege SP. When empty (the CI default), ci_deploy.sh resolves run_as to the +# *deploying identity* via `databricks current-user me`, so run_as == the job's creator and no +# servicePrincipal.user grant is needed (the same pattern as the demo asset-bundle test and the +# SDK-created integration jobs). Databricks only lets you bind a *different* SP as run_as if the +# deployer holds servicePrincipal.user on it — which is why hard-coding TOOLS_CLIENT_ID 403'd. +RUNNER_SP = os.environ.get("DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID", "") + + +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 + + +def _databricks_oauth_m2m_token(host: str, client_id: str, client_secret: str) -> str: + """Mint a **Databricks** OAuth (M2M) access token straight from the workspace OIDC token endpoint. + + Uses ``{host}/oidc/v1/token`` (client-credentials, scope ``all-apis``) directly rather than the + SDK's ``auth_type`` routing: on Azure workspaces the SDK's oauth-m2m gets pulled toward Azure AD + by the ambient ARM_* env and sends ``all-apis`` to Azure's endpoint (AADSTS1002012 — Azure wants + ``/.default``), which would also yield an AAD token the Apps front-door rejects. Hitting + the Databricks OIDC endpoint directly guarantees a Databricks-issued token, which the front-door + accepts. Raises for a bad response so credential problems fail fast. + """ + resp = requests.post( + f"{host}/oidc/v1/token", + data={"grant_type": "client_credentials", "scope": "all-apis"}, + auth=(client_id, client_secret), + timeout=30, + ) + resp.raise_for_status() + token = resp.json().get("access_token", "") + assert token, f"no access_token in the OAuth response from {host}/oidc/v1/token" + 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 **Databricks OAuth** tokens (user U2M, or service-principal + M2M-with-secret minted at ``{host}/oidc/v1/token``) and rejects the acceptance harness's ambient + token with 401 — that ambient token is an **Azure AD** token (``iss=sts.windows.net``, + ``aud=2ff814a6-…`` = the AzureDatabricks API resource), which is valid for the control-plane REST + APIs but NOT for the Apps front-door. This is a token-*type* limitation, not a permission one + (confirmed by the 401 RCA in ``wait_until_ready``). + + So authenticate to the front-door via OAuth **M2M**: an SP client id + a **Databricks OAuth** + secret (``auth_type="oauth-m2m"`` uses ``{host}/oidc/v1/token`` → a Databricks token the + front-door accepts). Prefer the MCP-specific env (``DQX_MCP_APP_CLIENT_ID`` / + ``DQX_MCP_APP_CLIENT_SECRET``); in CI fall back to the acceptance vault's ``TOOLS_CLIENT_ID`` + + **``TOOLS_DATABRICKS_SECRET``** (the SP's *Databricks OAuth* secret — NOT ``TOOLS_CLIENT_SECRET``, + which is the Azure AD secret that yields the rejected token type). When none are set, fall back to + the ambient auth — a local ``databricks auth login`` OAuth profile is itself accepted. + """ + host, ambient_get_token = workspace_auth + client_id = os.environ.get("DQX_MCP_APP_CLIENT_ID") or os.environ.get("TOOLS_CLIENT_ID") + client_secret = os.environ.get("DQX_MCP_APP_CLIENT_SECRET") or os.environ.get("TOOLS_DATABRICKS_SECRET") + if not (client_id and client_secret): + return ambient_get_token # local OAuth profile (front-door accepts it) + _databricks_oauth_m2m_token(host, client_id, client_secret) # fail fast if the M2M credentials are bad + return lambda: _databricks_oauth_m2m_token(host, client_id, client_secret) + + +def _script_env(name_prefix: str, host: str, token: str) -> dict[str, str]: + return { + **os.environ, + "NAME_PREFIX": name_prefix, + "DQX_MCP_TEST_CATALOG": CATALOG, + # Pass through the explicit override (empty in CI, where ci_deploy.sh defaults run_as to the + # deploying identity). Local runs set this to a real dedicated runner SP. + "DQX_MCP_RUNNER_SERVICE_PRINCIPAL_ID": RUNNER_SP, + "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}=")), + "", + ) + + +# Mask anything credential-shaped before it can reach a log/skip message: a JWT (``eyJ…`.`.`…``), +# a ``Bearer `` header, or any long opaque run (40+ chars) that could be a token/secret. UUID +# identifiers (36 chars) and OAuth scope words fall below the threshold, so RCA claims survive. +_TOKENISH = re.compile(r"eyJ[\w-]+\.[\w-]+\.[\w-]+|Bearer\s+\S+|[A-Za-z0-9_\-]{40,}", re.IGNORECASE) + + +def _redact(text: str) -> str: + """Scrub token/secret-shaped substrings from server-controlled text before logging it.""" + return _TOKENISH.sub("[REDACTED]", text or "") + + +def _decode_jwt_claims(token: str) -> dict: + """Best-effort decode of a bearer for 401 RCA — header alg + a few non-sensitive claims, with NO + signature check and NEVER the raw token/signature. An opaque (non-JWT) token is itself the tell: + the Databricks Apps front-door wants an OAuth JWT, not a metadata/PAT-style token. + """ + parts = token.split(".") + if len(parts) != 3: + return {"format": "opaque (non-JWT) — a metadata/PAT-style token, not an OAuth JWT"} + + def _seg(seg: str) -> dict: + try: + return json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))) + except ValueError: # covers binascii.Error + json.JSONDecodeError + UnicodeDecodeError + return {} + + header, payload = _seg(parts[0]), _seg(parts[1]) + claims = {k: payload[k] for k in ("iss", "aud", "sub", "azp", "scope", "scp", "tid", "client_id") if k in payload} + return {"format": "jwt", "alg": header.get("alg"), "claims": claims} + + +@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]}" + + def env() -> dict[str, str]: + return _script_env(name_prefix, 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"), + # The SP the runner job actually runs as (the deploying identity when no override is set). + # The seed grants it READ on the contract volume so generate_rules_from_contract can read it. + "runner_service_principal": _emitted(result.stdout, "DQX_MCP_RUNNER_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) + + @property + def url(self) -> str: + return self._url + + def current_token(self) -> str: + """Mint the bearer this client sends — for 401 diagnostics only.""" + return self._get_token() + + 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 app *did* deploy and *did* respond + (it wasn't refused) — the Databricks Apps front-door rejected the request. Retrying can't change + that, so skip. The skip surfaces the *actual* response (status, ``WWW-Authenticate`` header, + body) instead of a guess: a **401** means the token itself was not accepted (authentication) — + e.g. the acceptance harness's metadata-service token, which the front-door does not honor; only + OAuth tokens (user U2M, or SP M2M-with-secret) with the app's ``user_api_scopes`` are. A **403** + would mean the identity authenticated but lacks CAN_USE (authorization). The suite 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: + resp = exc.response + status = resp.status_code if resp is not None else None + if status in {401, 403}: + www = _redact(resp.headers.get("WWW-Authenticate", "")) if resp is not None else "" + loc = _redact(resp.headers.get("Location", "")) if resp is not None else "" + body = _redact((resp.text or "")[:400]) if resp is not None else "" + # Decodes claims only — never emits the raw token (see _decode_jwt_claims). + token_info = _decode_jwt_claims(client.current_token()) + kind = ( + "token not accepted (authentication)" if status == 401 else "identity lacks CAN_USE (authorization)" + ) + # Single line on purpose: the acceptance harness only surfaces the first line of a + # skip reason, so the RCA fields must not be newline-separated. + pytest.skip( + f"MCP app front-door returned {status} at {client.url} — {kind}. The app DID " + f"deploy and respond (not refused). RCA token={token_info} || " + f"WWW-Authenticate={www!r} || Location={loc!r} || body[:400]={body!r} || " + "In CI auth comes from the metadata service; the Apps front-door needs an OAuth " + "token (U2M or SP M2M-with-secret) — set DQX_MCP_APP_CLIENT_ID / " + "DQX_MCP_APP_CLIENT_SECRET (an SP with CAN_USE) to run live; OAuth profile works 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, runner_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. + The **source table** is read through the caller's OBO definer's-rights view (the caller owns + this schema, so no SP grant is needed). The **contract** is read by the runner job *as the + runner SP* (``generate_rules_from_contract`` opens it locally), so the runner SP is granted + READ VOLUME on the contract volume. Persisting tools (``save_checks`` / + ``apply_checks_and_save_to_table``) write to the *caller's own per-user schema* + (``dqx_mcp_``, created + owned by the runner SP) — no grants on this seed schema needed. + + 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) + # Prefer the SP the deploy actually resolved run_as to; fall back to the explicit override. + runner_sp = (runner_sp or RUNNER_SP).strip() + 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()}") + + # The contract is read by the runner job as the runner SP, so it must live somewhere the runner + # SP can read — a UC volume in the seed schema. Reading a volume file via FUSE open() needs + # USE SCHEMA on the parent schema *and* READ VOLUME on the volume (USE CATALOG comes from setup). + run_sql(f"CREATE VOLUME IF NOT EXISTS {fq_schema}.contracts") + if runner_sp: + run_sql(f"GRANT USE SCHEMA ON SCHEMA {fq_schema} TO `{runner_sp}`") + run_sql(f"GRANT READ VOLUME ON VOLUME {fq_schema}.contracts TO `{runner_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, + "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..7d0a59655 --- /dev/null +++ b/tests/integration_mcp/test_mcp_server.py @@ -0,0 +1,248 @@ +"""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: list[dict] = [ + {"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 _assert_persisting_tools(client: McpClient, table: str) -> None: + """Exercise the tools that persist data — both write to the caller's private per-user schema. + + save_checks writes to ``.dqx_mcp_.customers_checks`` (loaded back by the FQN it + reports); apply_checks_and_save_to_table writes the clean/quarantine split to the same schema. + Kept as a helper so the end-to-end test stays within pylint's per-function statement budget. + """ + saved = client.call( + "save_checks", {"checks": EXPLICIT_CHECKS, "output_name": "customers_checks", "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 the runner created + assert saved.get("access_granted_to"), "save_checks should grant the caller access to the checks table" + saved_location = saved["location"] + assert ".dqx_mcp_" in saved_location and saved_location.endswith(".customers_checks"), saved_location + loaded = client.call("load_checks", {"location": saved_location}) + assert loaded["count"] == len(EXPLICIT_CHECKS) + assert {c["check"]["function"] for c in loaded["checks"]} == {c["check"]["function"] for c in EXPLICIT_CHECKS} + + applied = client.call( + "apply_checks_and_save_to_table", + { + "table_name": table, + "checks": EXPLICIT_CHECKS, + "output_name": "customers_clean", + "quarantine_name": "customers_quarantine", + "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 the runner created. + # access_granted_to is set only because BOTH created tables were granted (partial grants report null). + assert applied.get("access_granted_to"), "apply should grant the caller access to the outputs" + assert applied.get("output_schema", "").rsplit(".", 1)[-1].startswith("dqx_mcp_"), applied.get("output_schema") + assert applied["output_table"].endswith(".customers_clean"), applied["output_table"] + assert len(applied.get("granted_tables") or []) == 2, applied.get("granted_tables") + + # A caller-supplied output name that is a dotted FQN or starts with a digit is rejected up front + # (it would otherwise be interpolated unquoted into the FQN). Tolerate either surfacing: the MCP + # error may raise in the client, or come back as a non-successful payload — but it must NOT save. + for bad_name in ("catalog.schema.table", "2024_bad"): + try: + res = client.call("save_checks", {"checks": EXPLICIT_CHECKS, "output_name": bad_name}) + except Exception: + continue # rejected via raised MCP error — expected + assert not res.get("saved") and res.get("status") != "completed", f"bad output_name accepted: {res}" + + +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"], app["runner_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-10. Persisting tools (save_checks -> load_checks round-trip, then + # apply_checks_and_save_to_table) — both write to the caller's private per-user schema. + _assert_persisting_tools(client, table) + + # get_run_result for an unknown run_id returns a structured not_found (also confirms the + # ownership/IDOR guard handles a missing run cleanly rather than erroring). + unknown = client.call("get_run_result", {"run_id": 999999999999999}) + assert unknown["status"] == "not_found", unknown + + # 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)