diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..a9e0e81c --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "dataiku", + "owner": { + "name": "Dataiku" + }, + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "plugins": [ + { + "name": "dataiku-headless", + "source": "./", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "category": "developer-tools", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 96b53f5b..825c0357 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,10 +6,13 @@ "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "plugins": [ { - "name": "dataiku-headless", + "name": "headless", "source": "./", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "category": "developer-tools" } - ] + ], + "renames": { + "dataiku-headless": "headless" + } } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d3959e4d..1074a9cc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "dataiku-headless", + "name": "headless", "displayName": "Dataiku Headless", - "version": "0.8.0", + "version": "0.9.0", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "author": { "name": "Dataiku", diff --git a/.mcp.json b/.codex-plugin/mcp.json similarity index 100% rename from .mcp.json rename to .codex-plugin/mcp.json diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index abc0a048..f4d9c220 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dataiku-headless", - "version": "0.8.0", + "version": "0.9.0", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "author": { "name": "Dataiku", @@ -11,7 +11,7 @@ "license": "Apache-2.0", "keywords": ["dataiku", "cobuild"], "skills": "./skills/", - "mcpServers": "./.mcp.json", + "mcpServers": "./.codex-plugin/mcp.json", "interface": { "displayName": "Dataiku Headless", "shortDescription": "Build AI and data apps", diff --git a/.cortex-plugin/plugin.json b/.cortex-plugin/plugin.json index b79863ac..f9d7706c 100644 --- a/.cortex-plugin/plugin.json +++ b/.cortex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dataiku-headless", "displayName": "Dataiku Headless", - "version": "0.8.0", + "version": "0.9.0", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "author": { "name": "Dataiku", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000..a463286b --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "dataiku", + "owner": { + "name": "Dataiku" + }, + "plugins": [ + { + "name": "dataiku-headless", + "source": "./", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "category": "developer-tools" + } + ], + "metadata": { + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets." + } +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index e3249bc9..f7281821 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dataiku-headless", - "version": "0.8.0", + "version": "0.9.0", "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", "author": { "name": "Dataiku" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 00000000..a463286b --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "dataiku", + "owner": { + "name": "Dataiku" + }, + "plugins": [ + { + "name": "dataiku-headless", + "source": "./", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "category": "developer-tools" + } + ], + "metadata": { + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets." + } +} diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json new file mode 100644 index 00000000..eddf20b6 --- /dev/null +++ b/.github/plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "dataiku-headless", + "version": "0.9.0", + "description": "Connect your agent to Dataiku: inspect projects, datasets, recipes, ML, and agents with typed MCP tools, and drive Dataiku Cobuild to build project-level assets.", + "author": { + "name": "Dataiku", + "url": "https://www.dataiku.com/" + }, + "homepage": "https://github.com/dataiku/dataiku-headless", + "repository": "https://github.com/dataiku/dataiku-headless", + "license": "Apache-2.0", + "keywords": [ + "dataiku", + "cobuild" + ], + "mcpServers": { + "dataiku": { + "type": "stdio", + "command": "uv", + "args": [ + "run", + "--quiet", + "--locked", + "--script", + "${PLUGIN_ROOT}/runtime/run_mcp.py", + "--transport", + "stdio" + ], + "cwd": "${PLUGIN_ROOT}" + } + }, + "skills": "./skills/" +} diff --git a/AGENTS.md b/AGENTS.md index 8b68c63e..d9e4180b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Use this file when changing this repository. It is not an operating guide for us - Tool registration imports: `dataiku_mcp/__init__.py`. - Project dependencies, Python support, version, and CLI entry points: `pyproject.toml`. - Standalone server dependency pins and Python floor: the PEP 723 block in `runtime/run_mcp.py`. -- Plugin launch configuration: `.mcp.json`, `.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`. +- Plugin launch configuration: `.codex-plugin/mcp.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and `.github/plugin/plugin.json` with its sibling marketplace manifest. Keep client-specific MCP configurations scoped to their manifests to avoid discovery conflicts. - User-facing installation and architecture overview: `README.md`. - Release behavior: `RELEASE.md` and `.github/workflows/bump.yml`. - CI behavior: `.github/workflows/ci.yml`, `.github/workflows/pr-title.yml`, and `.github/workflows/cla.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index fce8d6a8..a23ffe03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.9.0 (2026-11-05) + +### Feat + +- **setup**: manage saved instance connections (#199) +- **plugins**: install from Git and manage plugin code environments (#193) +- **tools**: enforce instance-type policies (#179) +- **plugins**: support GitHub Copilot CLI (#198) + +### Fix + +- **tls**: trust operating system certificate authorities (#204) +- **datasets**: recompute metrics before reading them (#205) +- **plugins**: shorten Claude Code plugin and tool names for Bedrock; reinstall with `/plugin install headless@dataiku` (#201, #207) + ## v0.8.0 (2026-10-08) ### Feat diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 2266a1ca..3e25be0b 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -92,6 +92,8 @@ export DKU_API_KEY="your-api-key" ## Tool Metadata +- Every registered tool declares `meta.allowed_instance_types` using the shared presets in `dataiku_mcp/instance_policy.py`. Middleware enforces this declaration against the request-pinned instance before DSS token exchange or dispatch; missing or malformed declarations fail closed. Keep the complete catalog visible and extend the exact policy contract in `tests/test_instance_policy.py` when adding or changing a tool. + - Give each tool a short human title and a one-line description of what it is for and when to reach for it. - The tool catalog is re-sent to the model on every turn of every session, used or not. Keep workflow detail and safety rules in the relevant `skills/**/references/` guide, which loads on demand, and describe only parameters whose correct use a name and type cannot convey. - Set `readOnlyHint`, `destructiveHint`, and `openWorldHint` explicitly, classified from the handler's actual code paths rather than its name. Omit `idempotentHint` where it cannot apply. A tool whose effect depends on a runtime argument is catalogued at its maximum possible effect. @@ -103,12 +105,14 @@ export DKU_API_KEY="your-api-key" ## Cobuild Write-Routing Convention - This server intentionally does not expose direct create/update/delete tools for in-project flow and analytic assets (recipes, ML analyses, dashboards, insights, agents, agent tools, scenarios, webapps, wiki articles, data quality rules, knowledge banks, semantic models, evaluation stores). Project-level building goes through `dataiku_mcp/tools/cobuild.py`'s Cobuild conversation tools instead. `set_container_exec_config` is the narrow, placement-only exception; all other changes stay with Cobuild. - Do not add a new direct write tool for an in-project asset type beyond the fixed exceptions below. If a gap in Cobuild's coverage is found, note it in the relevant SKILL.md rather than adding an MCP write tool around it. -- A new direct write tool is only justified when the operation is cross-project, instance-level, or must happen before a project/Cobuild conversation exists. The fixed bootstrap exceptions are `create_project`, `create_upload_dataset`, `create_managed_folder`, `upload_file_to_managed_folder`, `write_project_library_file`, and `set_project_variables`; all but `create_project` write into a project, and each creates a container or carries local content rather than building logic. The other fixed exceptions are deterministic execution of existing assets (`build_datasets`, `run_recipe`, `run_scenario`, and `abort_job`, which stops an existing job) and deterministic container execution placement for an existing object (`set_container_exec_config`). +- A new direct write tool is only justified when the operation is cross-project, instance-level, or must happen before a project/Cobuild conversation exists. The fixed bootstrap exceptions are `create_project`, `create_upload_dataset`, `create_managed_folder`, `upload_file_to_managed_folder`, `write_project_library_file`, and `set_project_variables`; all but `create_project` write into a project, and each creates a container or carries local content rather than building logic. The other fixed exceptions are deterministic execution of existing assets (`build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, which stops an existing job, and `get_dataset_metrics`, which recomputes a dataset's existing metric probes so a read does not return stale stored values) and deterministic container execution placement for an existing object (`set_container_exec_config`). ## Fixed Tool Surface - `runtime/run_mcp.py` is the single launcher. It requires `--transport stdio` for the local plugin or `--transport http` for the authenticated Streamable HTTP deployment. Keep the transports explicitly selected and preserve the same registered tool catalog. - Register one directly visible tool catalog. Do not add an MCP search mode. +- Classify explicit tools with `PROJECT_READ_POLICY`, `PROJECT_EXECUTION_POLICY`, `ADMIN_POLICY` or `PROJECT_EDIT_POLICY`; use `INSTANCE_MANAGEMENT_POLICY` for connection controls. Execution includes status/polling, administration includes instance inspection and project folders, and project edits include project Cobuild. Only connection management is exempt from requiring a selected profile. +- `SUPPORTED_DSS_INSTANCE_TYPES` defines the currently supported backend types independently of all valid `InstanceType` values. Agent Management, Govern and Deployer remain configurable profiles with instance controls only until their product support lands. - The registered set is the contract; `tests/test_tool_surface.py` pins the exact catalog. Any tool add/remove/rename updates that pinned set in the same change. ## Guardrails diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..90ebd7bd --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,24 @@ +# Contributors + +Thank you to everyone who has contributed to Dataiku Headless! + +## Maintainers + +- **Patrick Phrayme (Masi-Phelps)** ([@pmasiphelps](https://github.com/pmasiphelps)) +- **Ned Martorell** ([@nmartorell](https://github.com/nmartorell)) +- **Youssef Jouini** ([@yjouini](https://github.com/yjouini)) +- **Christiaan Burrett** ([@crmapj](https://github.com/crmapj)) + +## Contributors + +- **Hannes Hapke** ([@hanneshapke](https://github.com/hanneshapke)) +- **Pierre Portal** ([@pportal2](https://github.com/pportal2)) +- **Jed Dougherty** ([@JedIV](https://github.com/JedIV)) +- **Michael Jeckmans** ([@mjeckmans](https://github.com/mjeckmans)) +- **Ludwig Ehlert** ([@ludwigehlert-gif](https://github.com/ludwigehlert-gif)) +- **Li-Heng Fu** ([@lhfudataiku](https://github.com/lhfudataiku)) +- **Arnaud Pichery** ([@apichery](https://github.com/apichery)) + +--- + +Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/README.md b/README.md index 798444a3..3622fb27 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Dataiku Headless is an MCP server with tools for working in Dataiku, plus skills that teach AI assistants how to use them. Connect it to a Dataiku instance, and your AI assistant can build data pipelines, models, dashboards, agents, and more. -Install the plugin from the [Claude Code](#claude-code-cli) or [Codex](#codex-cli) plugin marketplace, or install it as an agent plugin from this GitHub repository for Cursor, Snowflake CoCo, AWS Kiro, OpenCode, and more. +Install the plugin from the [Claude Code](#claude-code-cli), [Codex](#codex-cli), or [GitHub Copilot](#github-copilot-cli) plugin marketplace, or install it as an agent plugin from this GitHub repository for Cursor, Snowflake CoCo, AWS Kiro, OpenCode, and more. ## Requirements @@ -33,7 +33,9 @@ Dataiku Headless uses [uv 0.12.0 or later](https://docs.astral.sh/uv/getting-sta ## Get started with the Codex app or Claude Desktop app -Install the plugin, then select or ask **Set up Dataiku Headless**. In Claude Code, you can explicitly run `/dataiku-headless:dataiku-headless-setup`. The setup skill checks the local runtime, helps install uv when needed, and securely saves your Dataiku URL and personal API key on your local machine. +Install the plugin, then select or ask **Set up Dataiku Headless**. In Claude Code, you can explicitly run `/headless:dataiku-headless-setup`. The setup skill checks the local runtime, helps install uv when needed, and securely saves your Dataiku URL and personal API key on your local machine. + +To manage saved instances later, ask **Manage Dataiku instances**. The local browser manager groups instances by type and supports adding, editing, deleting, switching, and choosing a startup default. For Design and Automation, test before saving; other types can be saved without testing. Then select **Done**. Saved credentials stay hidden; environment and Code Studio instances are read-only. Here's how to do it in the Codex app; Claude has a similar plugin-install flow. @@ -47,7 +49,7 @@ Here, we use the Claude Code CLI to build a visual pipeline to clean up hospital ## Install with another agent -`dataiku-headless` also works with Snowflake CoCo (Cortex Code), Cursor, OpenCode, and custom MCP-compatible agents. Each plugin starts the same local MCP server; after installation, use the same setup flow above. +`dataiku-headless` also works with GitHub Copilot CLI, Snowflake CoCo (Cortex Code), Cursor, OpenCode, and custom MCP-compatible agents. Each plugin starts the same local MCP server; after installation, use the same setup flow above. > **First launch:** If Dataiku Headless tools are unavailable, first check that `uv` is installed and on your `PATH`: > @@ -68,7 +70,14 @@ codex plugin add dataiku-headless@dataiku ```bash claude plugin marketplace add https://github.com/dataiku/dataiku-headless.git -claude plugin install dataiku-headless@dataiku +claude plugin install headless@dataiku +``` + +### GitHub Copilot CLI + +```bash +copilot plugin marketplace add dataiku/dataiku-headless +copilot plugin install dataiku-headless@dataiku ``` ### Grok CLI @@ -81,7 +90,7 @@ grok plugin install dataiku/dataiku-headless --trust ```bash cursor agent plugin marketplace add github.com/dataiku/dataiku-headless -# Tip: use /plugins in interactive mode to install `dataiku-headless` plugin from this marketplace. +# Tip: use /plugins in interactive mode to install the `dataiku-headless` plugin from this marketplace. ``` ### Snowflake CoCo @@ -90,6 +99,11 @@ cursor agent plugin marketplace add github.com/dataiku/dataiku-headless cortex plugin install dataiku/dataiku-headless ``` +Each client uses its own plugin manifest. Codex, Claude Code, Copilot, and Cursor +also have separate marketplace catalogs. Claude Code +uses the plugin identifier `headless`; Codex, Cursor, Copilot, and CoCo use +`dataiku-headless`. CoCo installs directly from Git without a repo marketplace catalog. + ### Other AI assistants #### MCP @@ -128,6 +142,9 @@ For a quick reference to what Headless can inspect, what Cobuild builds, and the limited direct actions Headless supports, see the [Headless capability matrix](docs/capabilities.md). +To build or update a Dataiku plugin, follow the +[plugin development guide](skills/dataiku-headless/references/plugin-development.md). + ## MCP Server `dataiku_mcp` is a FastMCP server that exposes Dataiku operations as typed, async MCP tools. Tools are organized by domain: projects, project folders, flow, connections, datasets, data quality, managed folders, recipes, machine learning, insights, dashboards, scenarios, WebApps, wikis, agents, LLMs and knowledge banks, instance plugins, job management, administrative tasks, and Cobuild conversations. @@ -150,9 +167,9 @@ The reference library covers the main Dataiku object areas and workflows, includ ## Stdio onboarding and authentication -Outside a Code Studio, configure a connection using a personal API key: +Outside a Code Studio, configure an instance using a personal API key: -1. Ask the agent to **Set up Dataiku Headless** (or run `/dataiku-headless:dataiku-headless-setup` in Claude Code). +1. Ask the agent to **Set up Dataiku Headless** (or run `/headless:dataiku-headless-setup` in Claude Code). 2. Approve the MCP URL prompt. 3. Enter an instance name, choose its type, and supply its Dataiku URL and personal API key. 4. Repeat to add more instances; use `list_instances` and `switch_instance` while working. @@ -162,6 +179,21 @@ its injected API ticket. No personal API key or setup popup is needed. API keys and tickets never appear in MCP tool arguments or instance responses. +### HTTPS certificate trust + +The server uses the operating system certificate store through `truststore` for +HTTPS connections, including connection tests in the setup page. Corporate CAs +trusted on the machine running Dataiku Headless are trusted automatically; no +CA-bundle environment variable is needed. In a container or remote deployment, +the CA must be trusted inside that environment. + +Keep **Skip certificate verification** unchecked. If a saved profile previously +disabled verification, edit it in the instance manager, uncheck that option, and +test and save the connection again. + +System trust is activated by `runtime/run_mcp.py` for both stdio and HTTP +transports. Applications that import `dataiku_mcp` directly own their TLS setup. + ### Where configuration lives **Saved profiles.** The setup page stores named instances and a `default_instance` @@ -177,8 +209,11 @@ Each profile requires `instance_type`: `design`, `automation`, `deployer`, `gove `agent-management`. Existing stdio profiles missing this field are automatically assigned `design` and rewritten on disk before loading. This temporary migration is scheduled for deprecation by 0.9.0; existing values are preserved and validated. -The type metadata does not change client selection or tool availability. -Selecting Govern records its type; Govern-specific API tools are not yet implemented. +The configured type determines which tools can run; see the +[instance-type policy](docs/capabilities.md#instance-types). Agent Management, Govern and Deployer +currently support instance controls only. Their status is `unsupported`, without +a DSS connection check. The setup page allows these profiles to be saved without testing. +Design and Automation require a successful connection test. **Environment override.** To select an explicit target, including inside a Code Studio, set these three variables in your environment or copy @@ -204,7 +239,7 @@ empty ones. Direct package imports do not load `.env`. 3. The saved profiles' `default_instance`. All sources remain available through `list_instances` and `switch_instance`; -use `get_current_instance` to verify the active connection. Environment instances +use `get_current_instance` to verify the active instance. Environment instances take precedence over profiles with the same name. The explicit target must have a different name from `dataiku-from-code-studio` when both exist. Missing required credentials or environment types, unsupported types, and duplicate environment names fail startup. @@ -258,7 +293,7 @@ The Dataiku Headless marketplace plugin uses stdio transport. For customer-manag │ │ ├── jobs.py # Async job status/log/wait/abort tools │ │ ├── llms_and_knowledge_banks.py # LLM, Knowledge Bank, and RAG inspection tools │ │ ├── managed_folders.py # Managed folder inspection tools + local-file upload write -│ │ ├── plugins.py # Instance plugin listing, updates, and deletion +│ │ ├── plugins.py # Instance plugin lifecycle and managed code environments │ │ ├── project_folders.py # Project folder hierarchy inspection and organization tools │ │ ├── projects.py # Project inspection, creation, variables, and settings │ │ ├── scenarios.py # Scenario/run-history/messaging-channel inspection tools @@ -301,10 +336,16 @@ The Dataiku Headless marketplace plugin uses stdio transport. For customer-manag │ ├── plugin.json # Claude Code plugin manifest (skills + stdio MCP) │ └── marketplace.json # Marketplace catalog (single-plugin, source: "./") ├── .cursor-plugin/ -│ └── plugin.json # Cursor-native plugin manifest +│ ├── plugin.json # Cursor-native plugin manifest +│ └── marketplace.json # Cursor marketplace catalog +├── .github/plugin/ +│ ├── plugin.json # GitHub Copilot CLI plugin manifest +│ └── marketplace.json # GitHub Copilot CLI marketplace +├── .agents/plugins/ +│ └── marketplace.json # Codex/ChatGPT marketplace catalog ├── .codex-plugin/ -│ └── plugin.json # Codex plugin manifest -├── .mcp.json # Bundled Codex/ChatGPT MCP config +│ ├── plugin.json # Codex plugin manifest +│ └── mcp.json # Codex/ChatGPT MCP config, scoped to this manifest ├── CODING_STANDARDS_AND_STRUCTURE.md # Contributor guide └── pyproject.toml ``` diff --git a/dataiku_mcp/config/stdio.py b/dataiku_mcp/config/stdio.py index 4dd12720..93d5bbc7 100644 --- a/dataiku_mcp/config/stdio.py +++ b/dataiku_mcp/config/stdio.py @@ -230,6 +230,78 @@ def set_current_instance(instance: DSSInstance) -> None: _current_instance = instance +def get_profile_config() -> StdioConfig: + """Refresh cached profiles and return a detached manager snapshot.""" + global _config, _current_instance + with _settings_lock: + config = _load_config() + _config = config + if _current_instance is not None and _current_instance.source == "config": + _current_instance = get_instances().get(_current_instance.name) + return config.model_copy(deep=True) + + +def _require_editable_name(name: str) -> None: + if any(instance.name == name for instance in _environment_instances): + raise ValueError("Environment instances must be changed in their environment.") + + +def save_instance_profile( + name: str, + instance: StdioDSSInstanceConfig, + *, + original_name: str | None, + expected_instance: StdioDSSInstanceConfig | None, + set_default: bool, + activate: bool, +) -> None: + """Create or explicitly edit a profile without losing its active/default identity.""" + global _config, _current_instance + with _settings_lock: + config = _load_config() + _require_editable_name(name) + if original_name is not None: + _require_editable_name(original_name) + if ( + original_name not in config.dss_instances + or config.dss_instances[original_name] != expected_instance + ): + raise ValueError( + "This instance changed. Reopen the editor and test again." + ) + if name != original_name and name in config.dss_instances: + raise ValueError("An instance with this name already exists.") + if original_name is not None: + del config.dss_instances[original_name] + config.dss_instances[name] = instance + if set_default: + config.default_instance = name + elif config.default_instance == original_name: + config.default_instance = None + was_current = ( + _current_instance is not None + and _current_instance.source == "config" + and _current_instance.name == original_name + ) + _save_config(config) + _config = config + if activate or was_current: + _current_instance = instance.to_instance(name) + + +def set_default_instance(name: str) -> None: + """Choose the saved profile used at startup when no environment target exists.""" + global _config + with _settings_lock: + config = _load_config() + _require_editable_name(name) + if name not in config.dss_instances: + raise ValueError("This saved instance no longer exists. Refresh the list.") + config.default_instance = name + _save_config(config) + _config = config + + def add_instance_to_config( name: str, url: str, @@ -266,16 +338,28 @@ def add_instance_to_config( } -def delete_instance_from_config(name: str) -> dict: +def delete_instance_from_config( + name: str, + *, + replacement: str | None = None, + expected_instance: StdioDSSInstanceConfig | None = None, +) -> dict: """Remove a profile-file instance and update the active/default selection.""" global _config, _current_instance with _settings_lock: config = _load_config() instances = config.dss_instances + _require_editable_name(name) if name not in instances: raise ValueError( f"Instance '{name}' not found in config file. Available: {list(instances)}" ) + if expected_instance is not None and instances[name] != expected_instance: + raise ValueError("This instance changed. Review it again before deleting.") + if replacement is not None: + _require_editable_name(replacement) + if replacement == name or replacement not in instances: + raise ValueError("Choose another saved instance as the replacement.") was_current = ( _current_instance is not None @@ -283,15 +367,19 @@ def delete_instance_from_config(name: str) -> dict: and _current_instance.name == name ) instances.pop(name) + environment_names = {instance.name for instance in _environment_instances} if config.default_instance == name: - config.default_instance = next(iter(instances), None) + config.default_instance = replacement or next( + (name for name in instances if name not in environment_names), None + ) _save_config(config) _config = config if was_current: + next_name = replacement or config.default_instance + if next_name in environment_names: + next_name = None _current_instance = ( - instances[config.default_instance].to_instance(config.default_instance) - if config.default_instance - else None + instances[next_name].to_instance(next_name) if next_name else None ) return { "deleted": name, diff --git a/dataiku_mcp/instance_policy.py b/dataiku_mcp/instance_policy.py new file mode 100644 index 00000000..2e77f5e0 --- /dev/null +++ b/dataiku_mcp/instance_policy.py @@ -0,0 +1,57 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Declared instance policies, enforced before acquiring backend credentials.""" + +from typing import get_args + +from .config.models import InstanceType + +# Product-node support is added separately from this Design/Automation foundation. +SUPPORTED_DSS_INSTANCE_TYPES = frozenset({"design", "automation"}) + +# These are @mcp.tool metadata policies. V1 tools expose individual actions; +# project authoring still routes through Cobuild and its fixed direct exceptions. +PROJECT_READ_POLICY = {"allowed_instance_types": sorted(SUPPORTED_DSS_INSTANCE_TYPES)} +PROJECT_EXECUTION_POLICY = { + "allowed_instance_types": sorted(SUPPORTED_DSS_INSTANCE_TYPES) +} +ADMIN_POLICY = {"allowed_instance_types": sorted(SUPPORTED_DSS_INSTANCE_TYPES)} +PROJECT_EDIT_POLICY = {"allowed_instance_types": ["design"]} +INSTANCE_MANAGEMENT_POLICY = {"allowed_instance_types": list(get_args(InstanceType))} + + +def require_instance_type( + tool_name: str, instance_type: InstanceType | None, metadata: dict | None +) -> None: + allowed = ( + metadata.get("allowed_instance_types") if isinstance(metadata, dict) else None + ) + if ( + not isinstance(allowed, list) + or not allowed + or any( + not isinstance(value, str) or value not in get_args(InstanceType) + for value in allowed + ) + or len(allowed) != len(set(allowed)) + ): + raise ValueError(f"Tool '{tool_name}' has an invalid instance-type policy.") + # Only the connection-management group can run before a profile is selected. + if instance_type is None and metadata == INSTANCE_MANAGEMENT_POLICY: + return + if instance_type not in allowed: + raise ValueError( + f"Tool '{tool_name}' is unavailable for instance type '{instance_type}'. Allowed types: {', '.join(allowed)}. Use switch_instance to choose a supported instance." + ) diff --git a/dataiku_mcp/server.py b/dataiku_mcp/server.py index 77693e4a..eb8500d2 100644 --- a/dataiku_mcp/server.py +++ b/dataiku_mcp/server.py @@ -19,6 +19,7 @@ from typing import Any from fastmcp import FastMCP +from fastmcp.exceptions import NotFoundError from fastmcp.server.auth import MultiAuth from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.server.auth.providers.azure import AzureProvider @@ -29,6 +30,11 @@ from .auth import exchange_http_token from .config import http, request, stdio +from .instance_policy import ( + INSTANCE_MANAGEMENT_POLICY, + SUPPORTED_DSS_INSTANCE_TYPES, + require_instance_type, +) logger = logging.getLogger("dataiku-mcp") @@ -77,17 +83,6 @@ def _http_auth() -> JWTVerifier | MultiAuth: return MultiAuth(server=interactive_provider, verifiers=direct_token_verifier) -async def _tool_requires_dss_token( - context: MiddlewareContext[CallToolRequestParams], -) -> bool: - fastmcp_context = context.fastmcp_context - if fastmcp_context is None: - return True - - tool = await fastmcp_context.fastmcp.get_tool(context.message.name) - return tool is None or DSS_INDEPENDENT_TOOL_TAG not in tool.tags - - class RequestContextMiddleware(Middleware): """Bind request identity, active instance, and delegated DSS credentials.""" @@ -109,7 +104,30 @@ async def on_call_tool( try: pinned_instance_reset_token = request.pin_current_instance() - if access_token is not None and await _tool_requires_dss_token(context): + tool_name = context.message.name + tool_server = ( + context.fastmcp_context.fastmcp if context.fastmcp_context else mcp + ) + tool = await tool_server.get_tool(tool_name) + if tool is None: + raise NotFoundError(f"Unknown tool: {tool_name!r}") + try: + instance = request.get_pinned_instance() + except ValueError: + if tool.meta != INSTANCE_MANAGEMENT_POLICY: + raise + instance = None + require_instance_type( + tool_name, instance.instance_type if instance else None, tool.meta + ) + requires_dss_token = ( + instance is not None and DSS_INDEPENDENT_TOOL_TAG not in tool.tags + ) + if tool_name == "get_current_instance" and instance is not None: + requires_dss_token = ( + instance.instance_type in SUPPORTED_DSS_INSTANCE_TYPES + ) + if access_token is not None and requires_dss_token: dss_token = await exchange_http_token(access_token.token) dss_token_reset_token = request.bind_http_dss_token(dss_token) return await call_next(context) diff --git a/dataiku_mcp/setup_page.html b/dataiku_mcp/setup_page.html new file mode 100644 index 00000000..caa244a8 --- /dev/null +++ b/dataiku_mcp/setup_page.html @@ -0,0 +1,1223 @@ + + + + + + + Manage Dataiku instances · Dataiku Headless + + + + + +
+
+ __BIRD__DataikuHeadless +
+ +
+
+
+
+

Manage Dataiku instances

+

Manage the Dataiku instances your agent can use.

+
+ +
+
+ +
+ + +
+
+
+ +
+ +
+ + + + + diff --git a/dataiku_mcp/setup_server.py b/dataiku_mcp/setup_server.py index 58c55590..977c474f 100644 --- a/dataiku_mcp/setup_server.py +++ b/dataiku_mcp/setup_server.py @@ -12,28 +12,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Loopback-only browser setup for Dataiku instance credentials.""" +"""Temporary loopback instance manager for local stdio profiles.""" +import hashlib +import hmac +import json import secrets import threading import webbrowser -from dataclasses import dataclass -from html import escape +from dataclasses import dataclass, field +from functools import partial from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from typing import get_args from urllib.parse import parse_qs, urlsplit import dataikuapi -from .config import request, stdio -from .config.models import InstanceType +from .config import stdio +from .config.models import InstanceType, StdioConfig, StdioDSSInstanceConfig +from .instance_policy import SUPPORTED_DSS_INSTANCE_TYPES SESSION_LIFETIME_SECONDS = 10 * 60 MAX_REQUEST_BYTES = 16 * 1024 _active_session: "SetupSession | None" = None _session_lock = threading.Lock() +_revision_key = secrets.token_bytes(32) + + +class _ConnectionTestError(ValueError): + """A connection failure whose underlying details can be expanded in the UI.""" + -# Keep the setup server single-route: this is the bird from the checked-in lockup. DATAIKU_BIRD_SVG = """' if error else "" - ) - status_markup = ( - f'
{escape(status)}
' if status else "" +def _snapshot(config: StdioConfig | None = None) -> dict: + config = config if config is not None else stdio.get_profile_config() + instances = { + name: instance.to_instance(name) + for name, instance in config.dss_instances.items() + } + # Environment entries shadow saved profiles and remain read-only. + instances.update( + (name, instance) + for name, instance in stdio.get_instances().items() + if instance.source != "config" ) - is_initial_page = values is None - values = values or {} - name = escape(values.get("name", "")) - description = escape(values.get("description", "")) - instance_type = values.get("instance_type", "") - instance_type_options = "".join( - f'" - for value in get_args(InstanceType) + current = stdio.get_current_instance() + return { + "supported_instance_types": sorted(SUPPORTED_DSS_INSTANCE_TYPES), + "connections": [ + { + "name": instance.name, + "url": instance.url, + "instance_type": instance.instance_type, + "description": instance.description, + "has_api_key": instance.api_key is not None, + "no_check_certificate": instance.no_check_certificate, + "source": instance.source, + "revision": ( + _profile_revision(config.dss_instances[instance.name]) + if instance.source == "config" + else "" + ), + } + for instance in instances.values() + ], + "active": current.name if current else None, + "default": config.default_instance, + "environment_precedence": any(i.source != "config" for i in instances.values()), + } + + +def _profile_revision(instance: StdioDSSInstanceConfig) -> str: + """Opaque version for stale editor/delete detection, never a raw secret hash.""" + return hmac.new( + _revision_key, instance.model_dump_json().encode(), hashlib.sha256 + ).hexdigest() + + +def _page(state: dict | None = None, *, nonce: str = "") -> str: + template = Path(__file__).with_name("setup_page.html").read_text(encoding="utf-8") + payload = json.dumps(state or {}, ensure_ascii=True).replace("<", "\\u003c") + return ( + template.replace("__BIRD__", DATAIKU_BIRD_SVG) + .replace("__NONCE__", nonce) + .replace("__STATE__", payload) ) - url = escape(values.get("url", "")) - api_key = escape(values.get("api_key", "")) - set_default = " checked" if is_initial_page or values.get("set_default") else "" - no_check_certificate = " checked" if values.get("no_check_certificate") else "" - save_disabled = "" if connection_validated else " disabled" - return f""" - - - - - Configure Dataiku - - - -
-
{DATAIKU_BIRD_SVG}DataikuHeadless
-
-

Connect to Dataiku

-

Add or update the instance this agent can use.

-
Local only
Your credentials stay on this machine.The API key is saved with user-only (0600) file permissions. This page runs on 127.0.0.1 and expires after 10 minutes.
- {error_markup} -
-
-
A short name used when switching instances.
-
-
-
Enter the URL of your Dataiku instance.
-
Create one in Dataiku under Profile & Settings → API keys.
-
-
- -
-
Advanced options
- {status_markup} -
-
-
-
- -""" - - -def _success_page(name: str) -> str: - return f""" - - - - - Dataiku configured - - - -
- {DATAIKU_BIRD_SVG} -
✓
-

Dataiku is connected

-

{escape(name)} is now the active instance. You can close this tab and continue in your AI coding tool.

-
- -""" def _test_connection(values: dict) -> None: + if values["instance_type"] not in SUPPORTED_DSS_INSTANCE_TYPES: + raise ValueError( + f"Connection testing is unavailable for {values['instance_type']}. Save the profile without testing." + ) + client = None try: - client = dataikuapi.DSSClient(values["url"], values["api_key"]) - client._session.verify = not values["no_check_certificate"] + client = dataikuapi.DSSClient( + values["url"], + api_key=values["api_key"], + internal_ticket=values["api_ticket"], + no_check_certificate=values["no_check_certificate"], + ) + client._session.request = partial(client._session.request, timeout=15) client.get_auth_info() except Exception as exc: - raise ValueError( - "Could not connect. Check the URL, API key, and certificate settings." - ) from exc + raise _ConnectionTestError(str(exc)) from None + finally: + if client is not None: + client._session.close() + + +def _candidate(form: dict[str, list[str]]) -> tuple: + original_name = form.get("original_name", [""])[0] or None + config = stdio.get_profile_config() + connections = {i["name"]: i for i in _snapshot(config)["connections"]} + if original_name: + instance = connections.get(original_name) + if not instance or instance["source"] != "config": + raise ValueError("This saved instance cannot be edited. Refresh the list.") + existing = config.dss_instances.get(original_name) if original_name else None + if existing is not None and form.get("revision", [""])[0] != _profile_revision( + existing + ): + raise ValueError("This instance changed. Reopen the manager and test again.") + values = _validate_form(form, existing) + if values["name"] != original_name and values["name"] in connections: + raise ValueError("An instance with this name already exists.") + instance = StdioDSSInstanceConfig( + **{k: v for k, v in values.items() if k not in {"name", "set_default"}} + ) + return original_name, existing, instance, values["name"] + + +def _connection_identity(candidate: tuple) -> tuple: + original, existing, instance, _ = candidate + # Description and name changes do not change the authenticated target. + return ( + original, + existing, + instance.url, + instance.api_key, + instance.api_ticket, + instance.no_check_certificate, + instance.instance_type, + ) -def _connection_settings(values: dict) -> tuple[str, str, bool]: - return values["url"], values["api_key"], values["no_check_certificate"] +def _perform_action(session: SetupSession, form: dict[str, list[str]]) -> dict: + if session.expired or session.completed.is_set(): + raise ValueError( + "This instance manager has closed. Open it again from your agent." + ) + if session.settings_path != stdio.get_settings_path(): + raise ValueError("The profile file changed. Open the instance manager again.") + action = form.get("action", [""])[0] + name = form.get("name", [""])[0] + if action == "test": + session.validated_connection = None + session.validation_id = "" + candidate = _candidate(form) + _test_connection(candidate[2].model_dump()) + if session.expired: + raise ValueError( + "This instance manager expired. Open it again from your agent." + ) + session.validated_connection = _connection_identity(candidate) + session.validation_id = secrets.token_urlsafe(24) + return {"validation_id": session.validation_id} + if action == "save": + candidate = _candidate(form) + if candidate[2].instance_type in SUPPORTED_DSS_INSTANCE_TYPES and ( + not session.validation_id + or form.get("validation_id", [""])[0] != session.validation_id + or session.validated_connection != _connection_identity(candidate) + ): + raise ValueError( + "Test the connection successfully before saving. Test again after changing its connection settings." + ) + original, existing, instance, name = candidate + stdio.save_instance_profile( + name, + instance, + original_name=original, + expected_instance=existing, + set_default="set_default" in form, + activate="activate" in form, + ) + session.validated_connection = None + session.validation_id = "" + elif action == "switch": + state = _snapshot() + selected = next((i for i in state["connections"] if i["name"] == name), None) + if selected is None: + raise ValueError("This instance no longer exists. Refresh the list.") + if selected["source"] == "config": + instance = stdio.get_profile_config().dss_instances[name].to_instance(name) + else: + instance = stdio.get_instances()[name] + stdio.set_current_instance(instance) + elif action == "default": + stdio.set_default_instance(name) + elif action == "delete": + config = stdio.get_profile_config() + current = stdio.get_current_instance() + if name not in config.dss_instances: + raise ValueError("This saved instance no longer exists. Refresh the list.") + if form.get("revision", [""])[0] != _profile_revision( + config.dss_instances[name] + ): + raise ValueError( + "This instance changed. Reopen the manager before deleting." + ) + needs_replacement = config.default_instance == name or ( + current and current.name == name + ) + others = [ + i["name"] + for i in _snapshot(config)["connections"] + if i["source"] == "config" and i["name"] != name + ] + replacement = form.get("replacement", [""])[0] or None + if needs_replacement and others and replacement not in others: + raise ValueError("Choose another saved instance as the replacement.") + stdio.delete_instance_from_config( + name, replacement=replacement, expected_instance=config.dss_instances[name] + ) + elif action != "done": + raise ValueError("Unknown instance management action.") + state = _snapshot() + if action == "done": + session.result = { + "active_instance": state["active"], + "connection_count": len(state["connections"]), + } + session.timer.cancel() + session.validated_connection = None + session.validation_id = "" + session.completed.set() + return {"state": state} def _make_handler(token: str, expected_host: str, session_state: SetupSession | None): @@ -265,17 +337,29 @@ class SetupHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args) -> None: return - def _send_html(self, status: int, body: str) -> None: + def _send( + self, + status: int, + body: str, + *, + json_response: bool = False, + nonce: str = "", + ) -> None: encoded = body.encode("utf-8") self.send_response(status) - self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header( + "Content-Type", + "application/json; charset=utf-8" + if json_response + else "text/html; charset=utf-8", + ) self.send_header("Content-Length", str(len(encoded))) self.send_header("Cache-Control", "no-store") self.send_header( "Content-Security-Policy", - "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'", + f"default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'nonce-{nonce}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", ) - self.send_header("Referrer-Policy", "same-origin") + self.send_header("Referrer-Policy", "no-referrer") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(encoded) @@ -289,7 +373,26 @@ def do_GET(self) -> None: if not self._valid_request(): self.send_error(404) return - self._send_html(200, _page()) + if ( + session_state is None + or session_state.completed.is_set() + or session_state.settings_path != stdio.get_settings_path() + ): + self._send( + 410, + "This instance manager has closed. Open it again from your agent.", + ) + return + nonce = secrets.token_urlsafe(24) + try: + state = _snapshot() + except (ValueError, RuntimeError, OSError): + self._send( + 500, + "Could not read the saved instances. Check your local profile file.", + ) + return + self._send(200, _page(state, nonce=nonce), nonce=nonce) def do_POST(self) -> None: if not self._valid_request(): @@ -307,79 +410,91 @@ def do_POST(self) -> None: self.rfile.read(content_length).decode("utf-8"), keep_blank_values=True, ) - values = _validate_form(form) - action = form.get("action", ["save"])[0] - if action == "test": - _test_connection(values) - if session_state: - session_state.validated_connection = _connection_settings( - values - ) - self._send_html( - 200, - _page( - status="Connection successful. You can now save this instance.", - values=values, - connection_validated=True, - ), - ) - return - if action != "save": - raise ValueError("Unknown setup action.") - if ( - not session_state - or session_state.validated_connection - != _connection_settings(values) - ): - raise ValueError( - "Test the connection successfully before saving. Test again after changing the URL, API key, or certificate setting." + if any(len(values) != 1 for values in form.values()): + raise ValueError("Duplicate form fields are not allowed.") + if session_state is None: + raise ValueError("Open the instance manager again.") + with session_state.lock: + result = _perform_action(session_state, form) + except _ConnectionTestError as error: + self._send( + 400, + json.dumps( + { + "error": "Could not connect. Check the URL, API key, and certificate settings.", + "details": str(error), + } + ), + json_response=True, + ) + return + except (UnicodeDecodeError, ValueError, RuntimeError) as error: + # Validation errors are deliberately authored here. Never return raw + # Pydantic/settings errors, which can contain profile or secret data. + if type(error) is ValueError: + message = str(error) + if message.startswith("Invalid stdio settings"): + message = "Could not read the saved instances. Check your local profile file." + else: + message = ( + "Could not process this request. Reopen the instance manager." ) - result = stdio.add_instance_to_config(**values) - request.set_current_instance(result["name"]) - except (UnicodeDecodeError, ValueError, RuntimeError) as exc: - self._send_html( - 400, _page(error=str(exc), values=locals().get("values")) + self._send(400, json.dumps({"error": message}), json_response=True) + return + except OSError: + self._send( + 500, + json.dumps( + { + "error": "Could not save the local profile file. Check its permissions and try again." + } + ), + json_response=True, ) return - - self._send_html(200, _success_page(result["name"])) - if session_state: - session_state.result = result - session_state.timer.cancel() - session_state.completed.set() - threading.Thread(target=self.server.shutdown, daemon=True).start() + self._send(200, json.dumps(result), json_response=True) + if session_state.completed.is_set(): + threading.Thread(target=session_state.close, daemon=True).start() return SetupHandler def start_setup_server(*, open_browser: bool = True) -> SetupSession: - """Start one temporary setup page on a random loopback port and URL token.""" + """Start a instance manager on a random loopback port and secret URL.""" global _active_session - with _session_lock: if _active_session: _active_session.close() - token = secrets.token_urlsafe(24) server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(token, "", None)) host = f"127.0.0.1:{server.server_port}" - url = f"http://{host}/{token}" thread = threading.Thread(target=server.serve_forever, daemon=True) - completed = threading.Event() def expire() -> None: session.expired = True + session.validated_connection = None + session.validation_id = "" session.completed.set() server.shutdown() + server.server_close() timer = threading.Timer(SESSION_LIFETIME_SECONDS, expire) timer.daemon = True - session = SetupSession(url, server, thread, timer, False, completed) + session = SetupSession( + f"http://{host}/{token}", + server, + thread, + timer, + False, + completed, + stdio.get_settings_path(), + ) server.RequestHandlerClass = _make_handler(token, host, session) thread.start() timer.start() - browser_opened = webbrowser.open(url, new=2) if open_browser else False - session.browser_opened = browser_opened + session.browser_opened = ( + webbrowser.open(session.url, new=2) if open_browser else False + ) _active_session = session - return _active_session + return session diff --git a/dataiku_mcp/tools/agent_reviews.py b/dataiku_mcp/tools/agent_reviews.py index ae538b1a..1fcda289 100644 --- a/dataiku_mcp/tools/agent_reviews.py +++ b/dataiku_mcp/tools/agent_reviews.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -24,6 +25,7 @@ @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agent Reviews", annotations={ "readOnlyHint": True, @@ -53,6 +55,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Agent Review", annotations={ "readOnlyHint": True, @@ -88,6 +91,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agent Review Tests", annotations={ "readOnlyHint": True, @@ -127,6 +131,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agent Review Runs", annotations={ "readOnlyHint": True, @@ -182,6 +187,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Agent Review Run Results", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/agents.py b/dataiku_mcp/tools/agents.py index 5e505077..b89f49b6 100644 --- a/dataiku_mcp/tools/agents.py +++ b/dataiku_mcp/tools/agents.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -49,6 +50,7 @@ def _get_sa(version): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agents", annotations={ "readOnlyHint": True, @@ -79,6 +81,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Agent Settings", annotations={ "readOnlyHint": True, @@ -155,6 +158,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agent Versions", annotations={ "readOnlyHint": True, @@ -187,6 +191,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Agent Tools", annotations={ "readOnlyHint": True, @@ -218,6 +223,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Agent Tool Settings", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/cobuild.py b/dataiku_mcp/tools/cobuild.py index fef96559..9d22d758 100644 --- a/dataiku_mcp/tools/cobuild.py +++ b/dataiku_mcp/tools/cobuild.py @@ -26,6 +26,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_EDIT_POLICY from ..auth import get_dss_client from ..config import request from ..executors import run_blocking, run_cobuild_blocking @@ -256,6 +257,7 @@ async def _wait_for_turn( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Start Cobuild Conversation", annotations={ "readOnlyHint": False, @@ -295,6 +297,7 @@ async def start_cobuild_conversation(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Send Cobuild Message", annotations={ "readOnlyHint": False, @@ -350,6 +353,7 @@ def call(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Answer Cobuild Deletion Confirmation", annotations={ "readOnlyHint": False, @@ -396,6 +400,7 @@ def call(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Answer Cobuild Question", annotations={ "readOnlyHint": False, @@ -456,6 +461,7 @@ def call(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Get Cobuild Turn Status", tags={DSS_INDEPENDENT_TOOL_TAG}, annotations={ @@ -480,6 +486,7 @@ async def get_cobuild_turn_status( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="List Cobuild Conversations", tags={DSS_INDEPENDENT_TOOL_TAG}, annotations={ diff --git a/dataiku_mcp/tools/code_environments.py b/dataiku_mcp/tools/code_environments.py index d97a293d..65ff8b44 100644 --- a/dataiku_mcp/tools/code_environments.py +++ b/dataiku_mcp/tools/code_environments.py @@ -20,6 +20,7 @@ from fastmcp import Context from pydantic import BaseModel, Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -212,6 +213,7 @@ def _apply_changes( @mcp.tool( + meta=ADMIN_POLICY, title="List Code Environments", annotations={ "readOnlyHint": True, @@ -304,6 +306,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Create Code Environment", annotations={ "readOnlyHint": False, @@ -382,6 +385,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Update Code Environment", annotations={ "readOnlyHint": False, @@ -469,6 +473,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Delete Code Environment", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/connections.py b/dataiku_mcp/tools/connections.py index a1fb856c..90912a3c 100644 --- a/dataiku_mcp/tools/connections.py +++ b/dataiku_mcp/tools/connections.py @@ -21,6 +21,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..executors import run_blocking from .utils.serialization import columnar, compact_json, omit_empty @@ -156,6 +157,7 @@ def _get_connection_category(connection_type: str) -> str | None: @mcp.tool( + meta=ADMIN_POLICY, title="List Connections", annotations={ "readOnlyHint": True, @@ -230,6 +232,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Get Connection Info", annotations={ "readOnlyHint": True, @@ -275,6 +278,7 @@ async def get_connection_info( @mcp.tool( + meta=ADMIN_POLICY, title="Test Connection", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/container_exec.py b/dataiku_mcp/tools/container_exec.py index 2954c55c..b77b01f3 100644 --- a/dataiku_mcp/tools/container_exec.py +++ b/dataiku_mcp/tools/container_exec.py @@ -22,6 +22,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_EDIT_POLICY from .. import mcp from .machine_learning.shared.common import require_single_ml_task from ..auth import get_dss_client @@ -151,6 +152,7 @@ def _apply_selection( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Set Container Execution Config", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/cross_project_sharing.py b/dataiku_mcp/tools/cross_project_sharing.py index f59c8078..7d0738df 100644 --- a/dataiku_mcp/tools/cross_project_sharing.py +++ b/dataiku_mcp/tools/cross_project_sharing.py @@ -17,6 +17,7 @@ from dataikuapi.utils import DataikuException from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -34,6 +35,7 @@ def _raise_if_forbidden(project_key: str, exc: DataikuException) -> None: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Shared Objects", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/dashboards.py b/dataiku_mcp/tools/dashboards.py index 0d594dcb..9ff725c8 100644 --- a/dataiku_mcp/tools/dashboards.py +++ b/dataiku_mcp/tools/dashboards.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -36,6 +37,7 @@ def _serialize_dashboard_list_item(item) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Dashboards", annotations={ "readOnlyHint": True, @@ -63,6 +65,7 @@ async def list_dashboards(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Dashboard Settings", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/data_collections.py b/dataiku_mcp/tools/data_collections.py index 46cef01a..18701031 100644 --- a/dataiku_mcp/tools/data_collections.py +++ b/dataiku_mcp/tools/data_collections.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..executors import run_blocking from .utils.serialization import columnar, compact_json @@ -24,6 +25,7 @@ @mcp.tool( + meta=ADMIN_POLICY, title="List Data Collections", annotations={ "readOnlyHint": True, @@ -56,6 +58,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="List Data Collection Objects", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/data_quality.py b/dataiku_mcp/tools/data_quality.py index 67d79eba..2acf1e49 100644 --- a/dataiku_mcp/tools/data_quality.py +++ b/dataiku_mcp/tools/data_quality.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -101,6 +102,7 @@ def _safe_status_by_partition( @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Data Quality Rules", annotations={ "readOnlyHint": True, @@ -131,6 +133,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Data Quality Status", annotations={ "readOnlyHint": True, @@ -184,6 +187,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Data Quality Rule", annotations={ "readOnlyHint": True, @@ -214,6 +218,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Data Quality Rule Results", annotations={ "readOnlyHint": True, @@ -264,6 +269,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Data Quality Rule History", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/datasets.py b/dataiku_mcp/tools/datasets.py index 2189cff0..f8f77de0 100644 --- a/dataiku_mcp/tools/datasets.py +++ b/dataiku_mcp/tools/datasets.py @@ -23,12 +23,20 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ( + PROJECT_EDIT_POLICY, + PROJECT_EXECUTION_POLICY, + PROJECT_READ_POLICY, +) from ..config import request from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking +from .utils.metrics import compute_report_errors as _compute_report_errors from .utils.metrics import parse_metric_ids as _parse_metric_ids from .utils.metrics import select_metrics as _select_metrics +from .utils.metrics import stale_metric_ids as _stale_metric_ids +from .utils.metrics import unrefreshed_metric_ids as _unrefreshed_metric_ids from .utils.serialization import columnar, compact_json, is_empty from .utils.validation import require_non_empty_string as _require_non_empty_string from .utils.validation import require_positive_int as _require_positive_int @@ -225,6 +233,7 @@ def _resolve_export_columns( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Create Uploaded Files Dataset", annotations={ "readOnlyHint": False, @@ -317,6 +326,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Datasets", annotations={ "readOnlyHint": True, @@ -352,6 +362,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Dataset Sample", annotations={ "readOnlyHint": True, @@ -429,6 +440,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Export Dataset to CSV", annotations={ "readOnlyHint": False, @@ -572,6 +584,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Dataset Info", annotations={ "readOnlyHint": True, @@ -618,6 +631,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Dataset Column Descriptions", annotations={ "readOnlyHint": True, @@ -669,6 +683,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Profile Dataset Columns", annotations={ "readOnlyHint": True, @@ -836,9 +851,10 @@ def _run(): @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Get Dataset Metrics", annotations={ - "readOnlyHint": True, + "readOnlyHint": False, "destructiveHint": False, "openWorldHint": False, }, @@ -853,19 +869,60 @@ async def get_dataset_metrics( description="JSON array of ids, e.g. '[\"records:COUNT_RECORDS\"]'. Every metric when omitted." ), ] = None, + refresh: Annotated[ + bool, + Field( + description="Recompute on the current data first; needs project write access. False returns stored values, which builds do not always update." + ), + ] = True, ) -> str: - """Read the metric values Dataiku last computed for a dataset.""" + """Read a dataset's metric values, recomputed on its current data by default.""" await ctx.info(f"Loading dataset metrics for {dataset_name} in {project_key}...") metric_ids_list = _parse_metric_ids(metric_ids) def _run(): dataset = get_dss_client().get_project(project_key).get_dataset(dataset_name) - result: dict = {} + result: dict = {"refreshed": False} + report = None + if refresh: + # Stored values are not a safe default: the built-in record count probe + # does not recompute on build, so it keeps describing older data. + try: + report = dataset.compute_metrics(metric_ids=metric_ids_list) + except Exception as exc: + result["refresh_error"] = str(exc) + else: + errors = _compute_report_errors(report) + if errors: + result["refresh_error"] = "; ".join(errors) + else: + result["refreshed"] = True try: raw_metrics = dataset.get_last_metric_values().get_raw().get("metrics", []) result.update(_select_metrics(raw_metrics, metric_ids_list)) except Exception as exc: result["metrics_warning"] = str(exc) + return result + if result["refreshed"]: + expected_ids = ( + metric_ids_list + if metric_ids_list is not None + else [metric["metric"]["id"] for metric in result["metrics"]] + ) + unrefreshed = _unrefreshed_metric_ids(report, expected_ids) + if unrefreshed: + result["refreshed"] = False + result["unrefreshed_metric_ids"] = unrefreshed + result["refresh_error"] = ( + "Some metrics were not recomputed; stored values may be stale." + ) + if not result["refreshed"]: + last_build = dataset.get_info().get_raw().get("lastBuild") or {} + stale = _stale_metric_ids( + result["metrics"], last_build.get("buildStartTime") + ) + if stale: + result["stale_metric_ids"] = stale return result return compact_json(await run_blocking(_run)) diff --git a/dataiku_mcp/tools/evaluation_stores.py b/dataiku_mcp/tools/evaluation_stores.py index 704b817b..a847ad2b 100644 --- a/dataiku_mcp/tools/evaluation_stores.py +++ b/dataiku_mcp/tools/evaluation_stores.py @@ -19,6 +19,7 @@ from dataikuapi.utils import DataikuException from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -27,6 +28,7 @@ @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Evaluation Store Details", annotations={ "readOnlyHint": True, @@ -92,6 +94,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Evaluation Stores", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/flow.py b/dataiku_mcp/tools/flow.py index a1141ed9..a64e72df 100644 --- a/dataiku_mcp/tools/flow.py +++ b/dataiku_mcp/tools/flow.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -301,6 +302,7 @@ def write_sources(kept: list) -> None: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Flow Graph", annotations={ "readOnlyHint": True, @@ -357,6 +359,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Flow Zones", annotations={ "readOnlyHint": True, @@ -400,6 +403,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Flow Object Metadata", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/general_settings.py b/dataiku_mcp/tools/general_settings.py index cd9eeee0..ac35597d 100644 --- a/dataiku_mcp/tools/general_settings.py +++ b/dataiku_mcp/tools/general_settings.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client, require_admin from ..executors import run_blocking @@ -157,6 +158,7 @@ def _serialize_spark_config(raw: dict, include_details: bool) -> dict: @mcp.tool( + meta=ADMIN_POLICY, title="List Container Execution Configs", annotations={ "readOnlyHint": True, @@ -191,6 +193,7 @@ async def list_container_exec_configs( @mcp.tool( + meta=ADMIN_POLICY, title="List Spark Configs", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/groups.py b/dataiku_mcp/tools/groups.py index 09800fab..c2e3afa1 100644 --- a/dataiku_mcp/tools/groups.py +++ b/dataiku_mcp/tools/groups.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client, require_admin from ..executors import run_blocking @@ -119,6 +120,7 @@ def _apply_changes(definition: dict, changes: dict) -> None: @mcp.tool( + meta=ADMIN_POLICY, title="List Groups", annotations={ "readOnlyHint": True, @@ -205,6 +207,7 @@ async def list_groups( @mcp.tool( + meta=ADMIN_POLICY, title="Create Group", annotations={ "readOnlyHint": False, @@ -326,6 +329,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Update Group", annotations={ "readOnlyHint": False, @@ -442,6 +446,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Delete Group", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/insights.py b/dataiku_mcp/tools/insights.py index 7287ebfb..c719a452 100644 --- a/dataiku_mcp/tools/insights.py +++ b/dataiku_mcp/tools/insights.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -35,6 +36,7 @@ def _serialize_insight_list_item(item) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Insights", annotations={ "readOnlyHint": True, @@ -62,6 +64,7 @@ async def list_insights(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Insight Settings", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/instances.py b/dataiku_mcp/tools/instances.py index 6cf45fc9..8317ab08 100644 --- a/dataiku_mcp/tools/instances.py +++ b/dataiku_mcp/tools/instances.py @@ -22,11 +22,13 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import INSTANCE_MANAGEMENT_POLICY, SUPPORTED_DSS_INSTANCE_TYPES from ..auth import get_dss_client from ..config import request, stdio from ..executors import run_blocking from ..server import DSS_INDEPENDENT_TOOL_TAG, mcp -from ..setup_server import SESSION_LIFETIME_SECONDS, start_setup_server +from ..setup_server import SESSION_LIFETIME_SECONDS, SetupSession, start_setup_server +from .utils.errors import safe_error_text from .utils.serialization import columnar, compact_json, omit_empty InstanceName = Annotated[ @@ -35,6 +37,7 @@ @mcp.tool( + meta=INSTANCE_MANAGEMENT_POLICY, title="List Dataiku Instances", annotations={ "readOnlyHint": True, @@ -69,6 +72,7 @@ async def list_instances(ctx: Context) -> str: @mcp.tool( + meta=INSTANCE_MANAGEMENT_POLICY, title="Switch Dataiku Instance", annotations={ "readOnlyHint": False, @@ -86,6 +90,7 @@ async def switch_instance(name: InstanceName, ctx: Context) -> str: @mcp.tool( + meta=INSTANCE_MANAGEMENT_POLICY, title="Delete Dataiku Instance", annotations={ "readOnlyHint": False, @@ -107,6 +112,7 @@ async def delete_instance(name: InstanceName, ctx: Context) -> str: @mcp.tool( + meta=INSTANCE_MANAGEMENT_POLICY, title="Get Current Instance", annotations={ "readOnlyHint": True, @@ -115,21 +121,23 @@ async def delete_instance(name: InstanceName, ctx: Context) -> str: }, ) async def get_current_instance(ctx: Context) -> str: - """Confirm which instance is active, whether it can be reached, and its version.""" + """Inspect the active profile, DSS connection and failure details, or an unsupported node type.""" # Strip both credential types from the response, including on failure. current_instance = asdict(request.get_pinned_instance()) current_instance.pop("api_key", None) current_instance.pop("api_ticket", None) current_instance.pop("encrypted_rpc_cert_path", None) + if current_instance["instance_type"] not in SUPPORTED_DSS_INSTANCE_TYPES: + current_instance["connection_status"] = "unsupported" + return compact_json(omit_empty(current_instance)) current_instance["connection_status"] = "failed" try: - client = get_dss_client() version = await run_blocking( - lambda: client.get_instance_info().raw.get("dssVersion") or "" + lambda: get_dss_client().get_instance_info().raw.get("dssVersion") or "" ) - except Exception: - pass + except Exception as exc: + current_instance["error"] = safe_error_text(exc) else: current_instance["connection_status"] = "connected" current_instance["dataiku_version"] = version @@ -138,18 +146,45 @@ async def get_current_instance(ctx: Context) -> str: return compact_json(result) +async def _start_connection_manager(*, open_browser: bool = True) -> SetupSession: + # Executor work keeps running after cancellation. Retain its result so the + # newly created manager can be closed before propagating cancellation. + startup = asyncio.create_task( + run_blocking(lambda: start_setup_server(open_browser=open_browser)) + ) + try: + return await asyncio.shield(startup) + except asyncio.CancelledError: + + async def cleanup(): + session = await startup + await run_blocking(session.close) + + closing = asyncio.create_task(cleanup()) + # MCP cancellation scopes can cancel each subsequent await as well. + # Keep cleanup independent until it has reclaimed the server. + while not closing.done(): + try: + await asyncio.shield(closing) + except asyncio.CancelledError: + pass + closing.result() + raise + + @mcp.tool( - title="Configure Dataiku Instance", + meta=INSTANCE_MANAGEMENT_POLICY, + title="Manage Dataiku Instances", annotations={ "readOnlyHint": False, - "destructiveHint": False, + "destructiveHint": True, "idempotentHint": False, "openWorldHint": False, }, tags={DSS_INDEPENDENT_TOOL_TAG}, ) async def configure_instance(ctx: Context) -> str: - """Connect a Dataiku instance, prompting the user in a local browser for its URL and key.""" + """Open the local browser to add, edit, delete, or select saved Dataiku instances.""" if request.is_http_request(): raise ValueError( "Instances are platform-managed in HTTP mode and cannot be configured." @@ -167,35 +202,39 @@ async def configure_instance(ctx: Context) -> str: "This MCP client does not advertise URL elicitation; opening the " "local configuration page directly." ) - session = start_setup_server() + session = await _start_connection_manager() return compact_json( { "interaction_mode": "direct_local_url", "setup_url": session.url, "browser_opened": session.browser_opened, "expires_in_seconds": SESSION_LIFETIME_SECONDS, - "next_step": "Open setup_url if the browser did not open automatically.", + "next_step": "Open setup_url if needed. Manage instances, then select Done.", } ) await ctx.info("Requesting permission to open the Dataiku configuration page...") - session = start_setup_server(open_browser=False) + session = await _start_connection_manager(open_browser=False) elicitation_id = secrets.token_urlsafe(18) - elicitation = await ctx.session.elicit_url( - message=( - "Open the local Dataiku configuration page to save an instance URL " - "and API key securely." - ), - url=session.url, - elicitation_id=elicitation_id, - related_request_id=ctx.request_id, - ) + try: + elicitation = await ctx.session.elicit_url( + message=( + "Open the local Dataiku instance manager to manage saved instances, " + "then select Done to return to your agent." + ), + url=session.url, + elicitation_id=elicitation_id, + related_request_id=ctx.request_id, + ) + except BaseException: + await run_blocking(session.close) + raise if elicitation.action != "accept": # Some clients advertise URL elicitation but decline the request because # the capability is gated behind an unreleased feature flag (e.g. Codex, # openai/codex#29344). Fall back to opening the local setup page directly. - fallback = start_setup_server() + fallback = await _start_connection_manager() return compact_json( { "interaction_mode": "direct_local_url", @@ -205,33 +244,34 @@ async def configure_instance(ctx: Context) -> str: "expires_in_seconds": SESSION_LIFETIME_SECONDS, "next_step": ( "The in-app prompt was declined or is unsupported by this " - "client; open setup_url to finish configuring Dataiku." + "client; open setup_url to manage instances, then select Done." ), } ) - await asyncio.to_thread( - session.completed.wait, - SESSION_LIFETIME_SECONDS + 1, - ) - if session.result is None: - session.close() + try: + await asyncio.to_thread( + session.completed.wait, + SESSION_LIFETIME_SECONDS + 1, + ) + if session.result is None: + return compact_json( + { + "interaction_mode": "mcp_url_elicitation", + "status": "expired" if session.expired else "incomplete", + } + ) + + await ctx.session.send_elicit_complete( + elicitation_id, + related_request_id=ctx.request_id, + ) return compact_json( { "interaction_mode": "mcp_url_elicitation", - "status": "expired" if session.expired else "incomplete", + "status": "completed", + "state": session.result, } ) - - await ctx.session.send_elicit_complete( - elicitation_id, - related_request_id=ctx.request_id, - ) - session.close() - return compact_json( - { - "interaction_mode": "mcp_url_elicitation", - "status": "configured", - "instance": session.result, - } - ) + finally: + await run_blocking(session.close) diff --git a/dataiku_mcp/tools/jobs.py b/dataiku_mcp/tools/jobs.py index a35bb2ab..be078068 100644 --- a/dataiku_mcp/tools/jobs.py +++ b/dataiku_mcp/tools/jobs.py @@ -19,9 +19,12 @@ from typing import Annotated, Literal from dataikuapi.dss.future import DSSFuture +from dataikuapi.utils import DataikuException from fastmcp import Context from pydantic import Field +from requests.exceptions import RequestException +from ..instance_policy import PROJECT_EXECUTION_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -117,6 +120,7 @@ def _per_dataset_outcomes(dataset_names: list[str], status_summary: dict) -> lis @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Build Datasets", annotations={ "readOnlyHint": False, @@ -258,6 +262,7 @@ def _start_job(): @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Run Recipe", annotations={ "readOnlyHint": False, @@ -404,7 +409,51 @@ def _run(): ) +def _plugin_future_state(state: dict) -> dict: + """Compact plugin receipts without settings, logs, or Git subprocess errors.""" + safe = { + key: state[key] + for key in ("jobId", "alive", "hasResult", "aborted", "runningTime") + if key in state + } + progress_states = (state.get("progress") or {}).get("states") or [] + if progress_states: + safe["progress"] = { + "states": [ + { + key: progress[key] + for key in ("name", "unit", "cur", "target") + if key in progress + } + for progress in progress_states + ] + } + result = state.get("result") + if isinstance(result, dict): + receipt = { + key: result[key] + for key in ("success", "needsReload", "needsRestart") + if key in result + } + desc = (result.get("pluginDesc") or {}).get("desc") or {} + if desc.get("id"): + receipt["pluginDesc"] = { + "desc": {key: desc[key] for key in ("id", "version") if key in desc} + } + if ( + result.get("success") is False + or result.get("installationError") + or result.get("errorMessage") + ): + receipt["error"] = ( + "Plugin operation failed. Inspect the plugin and Dataiku server logs before retrying; raw error details are omitted because they may contain credentials." + ) + safe["result"] = receipt + return safe + + @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Get Future Status", annotations={ "readOnlyHint": True, @@ -427,13 +476,34 @@ async def get_future_status( def _run(): future = DSSFuture(get_dss_client(), future_id) - state = future.get_state() if fetch_result else future.peek_state() + try: + state = future.peek_state() + except (DataikuException, RequestException): + raise RuntimeError( + "Could not inspect this future. It may have failed or its result may already have been read. Inspect the affected object before retrying; raw errors are omitted because they may contain credentials." + ) from None + plugin_action = (state.get("payload") or {}).get("action") in { + "plugin_install", + "plugin_delete", + } + if fetch_result: + try: + state = future.get_state() + except Exception: + if not plugin_action: + raise + raise RuntimeError( + "Could not retrieve the plugin operation result. Inspect installed plugins before retrying; raw error details are omitted because they may contain credentials." + ) from None + if plugin_action: + state = _plugin_future_state(state) return {"future_id": future_id, "state": state} return compact_json(await run_blocking(_run)) @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Get Job Status", annotations={ "readOnlyHint": True, @@ -466,6 +536,7 @@ async def get_job_status( @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Get Job Log", annotations={ "readOnlyHint": True, @@ -522,6 +593,7 @@ async def get_job_log( @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="List Jobs", annotations={ "readOnlyHint": True, @@ -562,6 +634,7 @@ async def list_jobs( @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Wait for Job", annotations={ "readOnlyHint": True, @@ -608,6 +681,7 @@ async def wait_for_job( @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Abort Job", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/licensing.py b/dataiku_mcp/tools/licensing.py index 9a0e404b..51b44dc8 100644 --- a/dataiku_mcp/tools/licensing.py +++ b/dataiku_mcp/tools/licensing.py @@ -20,6 +20,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client, require_admin from ..executors import run_blocking @@ -55,6 +56,7 @@ def _profile_rows(status: dict, include_capabilities: bool) -> list[dict]: @mcp.tool( + meta=ADMIN_POLICY, title="Get Licensing Status", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/llms_and_knowledge_banks.py b/dataiku_mcp/tools/llms_and_knowledge_banks.py index ce32cc31..6a788f9f 100644 --- a/dataiku_mcp/tools/llms_and_knowledge_banks.py +++ b/dataiku_mcp/tools/llms_and_knowledge_banks.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -83,6 +84,7 @@ def _collect_llms(project_key: str, purpose: str | None) -> list[dict[str, Any]] @mcp.tool( + meta=PROJECT_READ_POLICY, title="List LLMs", annotations={ "readOnlyHint": True, @@ -123,6 +125,7 @@ async def list_llms( @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get LLM Info", annotations={ "readOnlyHint": True, @@ -159,6 +162,7 @@ def _get() -> dict[str, Any]: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Knowledge Banks", annotations={ "readOnlyHint": True, @@ -177,6 +181,7 @@ async def list_knowledge_banks(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Knowledge Bank Settings", annotations={ "readOnlyHint": True, @@ -210,6 +215,7 @@ async def get_knowledge_bank_settings( @mcp.tool( + meta=PROJECT_READ_POLICY, title="Search Knowledge Bank", annotations={ "readOnlyHint": True, @@ -324,6 +330,7 @@ def _serialize_rag_llm_item(raw: dict) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Retrieval-Augmented LLMs", annotations={ "readOnlyHint": True, @@ -331,7 +338,7 @@ def _serialize_rag_llm_item(raw: dict) -> dict: "openWorldHint": False, }, ) -async def list_retrieval_augmented_llms(project_key: str, ctx: Context) -> str: +async def list_rag_llms(project_key: str, ctx: Context) -> str: """Find a project's retrieval-augmented LLMs and their IDs.""" project_key = _require_non_empty_string(project_key, "project_key") await ctx.info(f"Listing Retrieval-Augmented LLMs in {project_key}...") @@ -352,6 +359,7 @@ async def list_retrieval_augmented_llms(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Retrieval-Augmented LLM Settings", annotations={ "readOnlyHint": True, @@ -359,7 +367,7 @@ async def list_retrieval_augmented_llms(project_key: str, ctx: Context) -> str: "openWorldHint": False, }, ) -async def get_retrieval_augmented_llm_settings( +async def get_rag_llm_settings( project_key: str, retrieval_augmented_llm_id: str, ctx: Context, diff --git a/dataiku_mcp/tools/machine_learning/analyses.py b/dataiku_mcp/tools/machine_learning/analyses.py index 11e64b9f..8d4ce91f 100644 --- a/dataiku_mcp/tools/machine_learning/analyses.py +++ b/dataiku_mcp/tools/machine_learning/analyses.py @@ -21,6 +21,7 @@ from fastmcp import Context from pydantic import Field +from ...instance_policy import PROJECT_READ_POLICY from ...server import mcp from ...auth import get_dss_client from ...executors import run_blocking @@ -66,6 +67,7 @@ def slim_mltask_settings(raw_settings: dict) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List ML Analyses", annotations={ "readOnlyHint": True, @@ -144,6 +146,7 @@ def _list() -> dict[str, Any]: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get ML Analysis Summary", annotations={ "readOnlyHint": True, @@ -183,6 +186,7 @@ def _get() -> dict[str, Any]: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get ML Analysis Settings", annotations={ "readOnlyHint": True, @@ -218,6 +222,7 @@ def _get() -> dict[str, Any]: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List ML Analysis Models", annotations={ "readOnlyHint": True, @@ -261,6 +266,7 @@ def _list() -> dict[str, Any]: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get ML Model Details", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/machine_learning/saved_models.py b/dataiku_mcp/tools/machine_learning/saved_models.py index e6b0cf5e..f252f611 100644 --- a/dataiku_mcp/tools/machine_learning/saved_models.py +++ b/dataiku_mcp/tools/machine_learning/saved_models.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ...instance_policy import PROJECT_READ_POLICY from ...server import mcp from ...auth import get_dss_client from ...executors import run_blocking @@ -24,6 +25,7 @@ @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Saved Models", annotations={ "readOnlyHint": True, @@ -50,6 +52,7 @@ async def list_saved_models(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Saved Model Versions", annotations={ "readOnlyHint": True, @@ -79,6 +82,7 @@ async def list_saved_model_versions( @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Saved Model Version Details", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/managed_folders.py b/dataiku_mcp/tools/managed_folders.py index dbdf0dc6..50c49f41 100644 --- a/dataiku_mcp/tools/managed_folders.py +++ b/dataiku_mcp/tools/managed_folders.py @@ -19,6 +19,10 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ( + PROJECT_EDIT_POLICY, + PROJECT_READ_POLICY, +) from ..config import request from ..server import mcp from ..auth import get_dss_client @@ -31,6 +35,7 @@ @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Create Managed Folder", annotations={ "readOnlyHint": False, @@ -83,6 +88,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Managed Folders", annotations={ "readOnlyHint": True, @@ -115,6 +121,7 @@ async def list_managed_folders(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Managed Folder Contents", annotations={ "readOnlyHint": True, @@ -169,6 +176,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Managed Folder Info", annotations={ "readOnlyHint": True, @@ -208,6 +216,7 @@ async def get_managed_folder_info( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Upload File to Managed Folder", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/plugins.py b/dataiku_mcp/tools/plugins.py index 3924cc44..f99ce7e2 100644 --- a/dataiku_mcp/tools/plugins.py +++ b/dataiku_mcp/tools/plugins.py @@ -12,38 +12,52 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Dataiku instance plugin discovery, update, and deletion tools.""" +"""Dataiku instance plugin inspection, installation, environment setup, and removal.""" from __future__ import annotations import asyncio import io +import re import time from pathlib import Path +from typing import Annotated, Literal from zipfile import BadZipFile, ZipFile +from dataikuapi.dss.future import DSSFuture +from dataikuapi.utils import DataikuException from fastmcp import Context +from pydantic import Field +from requests.exceptions import RequestException from ..auth import get_dss_client from ..config import request from ..executors import run_blocking +from ..instance_policy import ADMIN_POLICY from ..server import mcp +from .code_environments import PythonInterpreter from .utils.errors import dataiku_message from .utils.parsing import parse_json_object from .utils.serialization import columnar, compact_json, omit_empty from .utils.validation import ( require_allowed_value as _require_allowed_value, + require_git_repository_url as _require_git_repository_url, require_int_in_range as _require_int_in_range, require_non_empty_string as _require_non_empty_string, require_non_negative_int as _require_non_negative_int, require_positive_int as _require_positive_int, + require_relative_posix_path as _require_relative_posix_path, ) -_SOURCES = {"store", "local_path"} +PluginSource = Literal["store", "local_path", "git"] _SEARCH_MODES = {"partial", "exact"} _MAX_TIMEOUT_SECONDS = 3600 # Store updates unpack an archive; code-environment rebuilds also install dependencies. _DEFAULT_TIMEOUT_SECONDS = 120 +_BUILD_TIMEOUT_SECONDS = 600 +# Automation futures omit their target. Retain only builds started by this process, +# scoped by caller and instance; after a restart, recover by environment name. +_BUILD_OWNERS: dict[tuple, str] = {} _MIN_POLL_INTERVAL_SECONDS = 2 _MAX_POLL_INTERVAL_SECONDS = 30 # A deletion refusal provides a compact sample; list_plugin_usages paginates the @@ -54,6 +68,8 @@ _MANIFEST_NAME = "plugin.json" # Noun phrases for error prose, keyed by the machine-readable operation name. _OPERATION_NOUNS = { + "install": "installation", + "ensure_plugin_code_env": "code environment build", "update": "update", "delete": "deletion", "rebuild_code_env": "code environment rebuild", @@ -140,6 +156,30 @@ def _installed_plugin(client, plugin_id: str) -> dict: raise ValueError(message) +def _require_absent(client, plugin_id: str) -> None: + if any(raw.get("id") == plugin_id for raw in client.list_plugins()): + raise ValueError( + f"Plugin '{plugin_id}' is already installed; use update_plugin." + ) + + +def _archive_code_env(archive: io.BytesIO) -> dict: + """Report which code-env descriptors the archive to upload declares.""" + languages = [] + with ZipFile(archive) as source: + # Dataiku's plugin layout names the R directory in upper case. + for language, directory in (("python", "python"), ("r", "R")): + descriptor = f"code-env/{directory}/desc.json" + if descriptor in source.namelist(): + parse_json_object(source.read(descriptor).decode("utf-8"), descriptor) + languages.append(language) + archive.seek(0) + return { + "status": "required" if languages else "not_required", + "languages": languages, + } + + def _find_manifest_member(archive: ZipFile) -> str: """Locate plugin.json at the ZIP root or inside a single wrapper directory. @@ -239,14 +279,18 @@ def _local_target(plugin_id: str | None, local_path: str) -> tuple[io.BytesIO, s def _resolve_source( - source: str, plugin_id: str | None, local_path: str | None + source: PluginSource, plugin_id: str | None, local_path: str | None ) -> tuple[str | None, str | None]: - """Validate ``source`` and the argument that source requires.""" - _require_allowed_value(source, "source", _SOURCES) + """Validate the argument ``source`` requires, and refuse local paths over HTTP.""" if source == "store": if local_path is not None: raise ValueError("'local_path' applies only to source='local_path'") return _require_non_empty_string(plugin_id or "", "plugin_id"), None + if request.is_http_request(): + raise ValueError( + "source='local_path' is unavailable in HTTP mode because it reads the MCP " + "host filesystem." + ) local_path = _require_non_empty_string(local_path or "", "local_path") if plugin_id is not None: plugin_id = _require_non_empty_string(plugin_id, "plugin_id") @@ -286,57 +330,27 @@ async def _wait_for_future(future, timeout_seconds: int) -> tuple[bool, dict]: return False, state -def _action_failure(result: dict) -> str | None: - """Return the failure message an update or delete result reports. - - Dataiku answers a failed plugin action with HTTP 200 and ``success: false``, so an - absent transport error is not evidence of success. Only the message is surfaced; - the accompanying stack trace is not. - """ - if result.get("success") is not False: - return None - error = result.get("installationError") or result.get("error") or {} - return error.get("message") or "Dataiku reported no error detail." - - -def _report_failure(result: dict) -> str | None: - """Return the failure message a nested Dataiku ``messages`` report carries. - - Code-environment actions carry no top-level ``success`` flag, so a missing one must - not be read as success. That nested report's own ``success`` field is not an - operation-success flag either: a successful environment creation returns - ``messages.success: false`` alongside an INFO-only ``INFO_CODEENV_IMPORT_OK`` entry. - Only ``error`` and ``fatal`` separate a real failure from that. - """ - messages = result.get("messages") or {} - if not (messages.get("error") or messages.get("fatal")): - return None - reported = [ - str(item.get("message") or item.get("details") or item.get("title") or "") - for item in messages.get("messages") or [] - if item.get("severity") == "ERROR" - ] - return "; ".join(filter(None, reported)) or "Dataiku reported no error detail." - - -def _terminal_result(operation: str, plugin_id: str, state: dict) -> dict: +def _terminal_result(operation: str, plugin_id: str | None, state: dict) -> dict: """Return a finished future's result, raising when it never produced one.""" noun = _OPERATION_NOUNS[operation] + target = f"plugin '{plugin_id}'" if plugin_id else "the Git plugin" if state.get("aborted", False): - raise RuntimeError(f"Dataiku aborted the {noun} of plugin '{plugin_id}'.") + raise RuntimeError(f"Dataiku aborted the {noun} of {target}.") if not state.get("hasResult", False): raise RuntimeError( - f"Dataiku stopped the {noun} of plugin '{plugin_id}' without reporting a " + f"Dataiku stopped the {noun} of {target} without reporting a " "result. Inspect the plugin before retrying." ) return state.get("result") or {} -def _successful_result(operation: str, plugin_id: str, state: dict) -> dict: +def _successful_result(operation: str, plugin_id: str | None, state: dict) -> dict: """Return a completed action's result, raising when Dataiku reports it failed.""" result = _terminal_result(operation, plugin_id, state) - detail = _action_failure(result) - if detail: + # Plugin actions can fail with HTTP 200 and success: false. + if result.get("success") is False: + error = result.get("installationError") or result.get("error") or {} + detail = error.get("message") or "Dataiku reported no error detail." noun = _OPERATION_NOUNS[operation] message = ( f"Dataiku reported the {noun} of plugin '{plugin_id}' failed: {detail}" @@ -349,37 +363,50 @@ def _build_outcome(result: dict, code_env_name: str) -> dict: """Report a code-environment build outcome instead of discarding the environment. The environment exists by this point, so a failed dependency install is reported - alongside its name rather than raised: the fix is to correct the plugin's - specification and rebuild, not to create another environment. + alongside its name rather than raised. """ - detail = _report_failure(result) - if detail is None: + messages = result.get("messages") + if not isinstance(messages, dict) or not any( + key in messages for key in ("error", "fatal", "messages", "maxSeverity") + ): + return {"status": "unknown", "code_env_name": code_env_name} + # messages.success can be false even on success; use error/fatal instead. + if not (messages.get("error") or messages.get("fatal")): return {"status": "completed", "code_env_name": code_env_name} + reported = [ + str(item.get("message") or item.get("details") or item.get("title") or "") + for item in messages.get("messages") or [] + if item.get("severity") == "ERROR" + ] + detail = "; ".join(filter(None, reported)) or "Dataiku reported no error detail." return { "status": "failed", "code_env_name": code_env_name, "error": detail, + # A rebuild keeps the interpreter and core-package set chosen at creation. "hint": ( - "The environment exists but its dependencies did not install. Fix the " - "plugin's code-env specification, then ask the user to rebuild the " - "existing environment in the Dataiku UI." + "Fix the plugin's requirements and rebuild. An interpreter or " + "core-package problem in desc.json needs the environment recreated in " + "the Dataiku UI." ), } -def _in_flight(operation: str, plugin_id: str, future, started: bool) -> dict: +def _in_flight(operation: str, plugin_id: str | None, future, started: bool) -> dict: """Describe an operation Dataiku is still working on. The raw future state is deliberately left out: the caller is routed to ``get_future_status``, which exists to return it on demand. """ - return { - "status": "started" if started else "still_running", - "operation": operation, - "plugin_id": plugin_id, - "future_id": future.job_id, - "hint": _FOLLOW_HINT, - } + return omit_empty( + { + "status": "started" if started else "still_running", + "operation": operation, + "plugin_id": plugin_id, + "future_id": future.job_id, + "hint": _FOLLOW_HINT, + } + ) def _reload_flags(result: dict) -> dict: @@ -409,7 +436,7 @@ def _blocker_columns(blockers: list[dict]) -> list[str]: async def _run_plugin_action( operation: str, - plugin_id: str, + plugin_id: str | None, future, wait_for_completion: bool, timeout_seconds: int, @@ -430,15 +457,18 @@ async def _run_plugin_action( if timed_out: return None, _in_flight(operation, plugin_id, future, started=False) result = _successful_result(operation, plugin_id, state) - return result, { - "status": "completed", - "operation": operation, - "plugin_id": plugin_id, - **_reload_flags(result), - } + return result, omit_empty( + { + "status": "completed", + "operation": operation, + "plugin_id": plugin_id, + **_reload_flags(result), + } + ) @mcp.tool( + meta=ADMIN_POLICY, title="List Installed Plugins", description="Find installed plugins and inspect their metadata before an update or deletion.", annotations={ @@ -525,6 +555,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="List Plugin Deletion Blockers", description="Inspect every object or unresolved component that blocks deletion of an installed plugin.", annotations={ @@ -576,9 +607,193 @@ def _run(): ) +def _git_error(operation: str, exc: Exception, future) -> RuntimeError: + # Git errors can include remote URLs, credentials, and subprocess output. + # Classify the problem, but never return the original text. + message = dataiku_message(exc).casefold() + auth_failure = any( + marker in message + for marker in ( + "authentication failed", + "permission denied", + "publickey", + "could not read username", + "invalid username or password", + "unauthorized", + "not authorized", + ) + ) + problem = ( + "Dataiku could not authenticate to or access the Git repository. " + if auth_failure + else f"The Git {_OPERATION_NOUNS[operation]} failed or its outcome could not be verified. " + ) + recovery = ( + f"Inspect future '{future.job_id}' with get_future_status before retrying. " + if future is not None and future.job_id + else "Check list_plugins before retrying. " + ) + return RuntimeError( + problem + + recovery + + "Check the remote URL, checkout, and subpath. For Git access, an " + "administrator must allow the repository in Dataiku's Git settings " + "before access is possible. Configure SSH or HTTPS credentials in Dataiku. Credentials " + "on the Headless machine do not apply. See the plugin guide's Git access section." + ) + + +async def _install_git( + repository_url: str, + checkout: str, + subpath: str | None, + wait_for_completion: bool, + timeout_seconds: int, +) -> str: + client = await run_blocking(get_dss_client) + future = None + try: + future = await run_blocking( + client.install_plugin_from_git, repository_url, checkout, subpath + ) + result, payload = await _run_plugin_action( + "install", None, future, wait_for_completion, timeout_seconds + ) + except Exception as exc: + raise _git_error("install", exc, future) from None + + response = {**payload, "source": "git", "code_env": {"status": "unknown"}} + if result is None: + return compact_json(response) + # The remote manifest, not a caller-provided id, determines the installed plugin. + plugin_id = ((result.get("pluginDesc") or {}).get("desc") or {}).get("id") + if plugin_id: + try: + plugin = await run_blocking( + lambda: _serialize_summary(_installed_plugin(client, plugin_id)) + ) + except Exception: + pass + else: + return compact_json({**response, "plugin_id": plugin_id, "plugin": plugin}) + return compact_json( + omit_empty( + { + **response, + "status": "verification_required", + "plugin_id": plugin_id, + "hint": "Dataiku reported installation completed. Verify the plugin with list_plugins before retrying or setting up its environment.", + } + ) + ) + + +@mcp.tool( + meta=ADMIN_POLICY, + title="Install Plugin", + description="Install a new instance plugin from the store, a local archive, or Git, then inspect its setup needs.", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "openWorldHint": False, + }, +) +async def install_plugin( + source: PluginSource, + ctx: Context, + plugin_id: str | None = None, + local_path: str | None = None, + wait_for_completion: bool = True, + timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS, + repository_url: str | None = None, + checkout: str | None = None, + subpath: str | None = None, +) -> str: + """Install a plugin that is not yet on the Dataiku instance. + + Requires administrator or plugin-developer permission. ``code_env.status`` says + whether to follow up with ensure_plugin_code_env. + + Args: + source: ``store``, ``local_path``, or ``git``. Dataiku fetches Git repositories. + plugin_id: Required for ``store``; optional manifest-id assertion for + ``local_path``. Omit for ``git``; the remote manifest determines the id. + local_path: Plugin directory containing plugin.json at its root, or a plugin + ZIP. Required for ``local_path``. + wait_for_completion: Wait up to timeout_seconds for a verified outcome. When + false, return a future_id to follow instead. + timeout_seconds: Inline wait budget, checked between polls. + repository_url: Remote Git URL without inline credentials. Git source only. + checkout: Explicit branch, tag, or commit to fetch. Required for Git. + subpath: Optional "Path in repository": relative folder containing plugin.json. + Omit when plugin.json is at the repository root. + """ + timeout_seconds = _require_int_in_range( + timeout_seconds, "timeout_seconds", 1, _MAX_TIMEOUT_SECONDS + ) + if source == "git": + if plugin_id is not None or local_path is not None: + raise ValueError( + "Omit 'plugin_id' and 'local_path' for source='git'; the remote plugin.json determines the id." + ) + repository_url = _require_git_repository_url(repository_url or "") + checkout = _require_non_empty_string(checkout or "", "checkout") + if subpath is not None: + subpath = _require_relative_posix_path(subpath, "subpath") + await ctx.info("Installing Dataiku plugin from Git...") + return await _install_git( + repository_url, checkout, subpath, wait_for_completion, timeout_seconds + ) + if any(value is not None for value in (repository_url, checkout, subpath)): + raise ValueError( + "'repository_url', 'checkout', and 'subpath' apply only to source='git'" + ) + plugin_id, local_path = _resolve_source(source, plugin_id, local_path) + await ctx.info(f"Installing Dataiku plugin from {source}...") + + def _start(): + if source == "store": + client = get_dss_client() + _require_absent(client, plugin_id) + return ( + client, + plugin_id, + client.install_plugin_from_store(plugin_id), + {"status": "unknown"}, + ) + archive, archive_id = _local_target(plugin_id, local_path) + requirements = _archive_code_env(archive) + client = get_dss_client() + _require_absent(client, archive_id) + return ( + client, + archive_id, + client.start_install_plugin_from_archive(archive), + requirements, + ) + + client, resolved_id, future, requirements = await run_blocking(_start) + result, payload = await _run_plugin_action( + "install", + resolved_id, + future, + wait_for_completion, + timeout_seconds, + ) + if result is None: + return compact_json({**payload, "source": source, "code_env": requirements}) + plugin = await run_blocking( + lambda: _serialize_summary(_installed_plugin(client, resolved_id)) + ) + return compact_json( + {**payload, "source": source, "plugin": plugin, "code_env": requirements} + ) + + @mcp.tool( + meta=ADMIN_POLICY, title="Update Plugin", - description="Update an installed plugin from the store or a local archive, optionally rebuilding its bound environment.", + description="Update an installed plugin from the store, a local archive, or Git, optionally rebuilding its bound environment.", annotations={ "readOnlyHint": False, "destructiveHint": True, @@ -586,13 +801,16 @@ def _run(): }, ) async def update_plugin( - source: str, + source: PluginSource, ctx: Context, plugin_id: str | None = None, local_path: str | None = None, rebuild_code_env: bool = False, wait_for_completion: bool = True, timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS, + repository_url: str | None = None, + checkout: str | None = None, + subpath: str | None = None, ) -> str: """Update an installed plugin in place. @@ -603,9 +821,8 @@ async def update_plugin( plugin update has already landed by then. Args: - source: ``store`` to update from the Dataiku plugin store, or ``local_path`` - to upload a newer version from this machine. - plugin_id: Installed plugin id. Required for ``store``. For ``local_path`` the + source: ``store``, ``local_path``, or ``git``. Dataiku fetches Git repositories. + plugin_id: Installed plugin id. Required for ``store`` and ``git``. For ``local_path`` the target is the id declared in plugin.json, and this argument, if given, must match it. local_path: Plugin directory containing plugin.json at its root, or a plugin @@ -616,12 +833,25 @@ async def update_plugin( answered with a completed result. Ignored while ``rebuild_code_env`` is true, which needs the update to have completed. timeout_seconds: Inline wait budget for each stage, checked between polls. + repository_url: Remote Git URL without inline credentials. Git source only. + checkout: Explicit branch, tag, or commit to fetch. Required for Git. + subpath: Optional "Path in repository": relative folder containing plugin.json. + Omit when plugin.json is at the repository root. """ - if request.is_http_request(): - raise ValueError( - "update_plugin is unavailable in HTTP mode because it can read the MCP host filesystem." - ) - plugin_id, local_path = _resolve_source(source, plugin_id, local_path) + if source == "git": + plugin_id = _require_non_empty_string(plugin_id or "", "plugin_id") + if local_path is not None: + raise ValueError("Omit 'local_path' for source='git'") + repository_url = _require_git_repository_url(repository_url or "") + checkout = _require_non_empty_string(checkout or "", "checkout") + if subpath is not None: + subpath = _require_relative_posix_path(subpath, "subpath") + else: + if any(value is not None for value in (repository_url, checkout, subpath)): + raise ValueError( + "'repository_url', 'checkout', and 'subpath' apply only to source='git'" + ) + plugin_id, local_path = _resolve_source(source, plugin_id, local_path) timeout_seconds = _require_int_in_range( timeout_seconds, "timeout_seconds", 1, _MAX_TIMEOUT_SECONDS ) @@ -640,10 +870,41 @@ def _start(): plugin = client.get_plugin(archive_id) return client, archive_id, plugin.start_update_from_zip(archive) - client, resolved_id, future = await run_blocking(_start) - result, payload = await _run_plugin_action( - "update", resolved_id, future, wait_for_completion, timeout_seconds - ) + if source == "git": + client = await run_blocking(get_dss_client) + await run_blocking(_installed_plugin, client, plugin_id) + resolved_id = plugin_id + future = None + try: + future = await run_blocking( + lambda: client.get_plugin(plugin_id).update_from_git( + repository_url, checkout, subpath + ) + ) + result, payload = await _run_plugin_action( + "update", resolved_id, future, wait_for_completion, timeout_seconds + ) + except Exception as exc: + raise _git_error("update", exc, future) from None + if result is not None: + actual_id = ((result.get("pluginDesc") or {}).get("desc") or {}).get("id") + if actual_id != resolved_id: + return compact_json( + omit_empty( + { + **payload, + "status": "verification_required", + "source": source, + "reported_plugin_id": actual_id, + "hint": "Dataiku did not report the expected plugin id. Inspect list_plugins before retrying. No environment rebuild was started.", + } + ) + ) + else: + client, resolved_id, future = await run_blocking(_start) + result, payload = await _run_plugin_action( + "update", resolved_id, future, wait_for_completion, timeout_seconds + ) if result is None: in_flight = {**payload, "source": source} if rebuild_code_env: @@ -677,32 +938,316 @@ async def _rebuild_code_env(client, plugin_id: str, timeout_seconds: int) -> dic reported alongside the successful update instead of discarding it. """ - def _start(): + bound = None + future = None + + def _inspect(): plugin = client.get_plugin(plugin_id) - bound = plugin.get_settings().get_raw().get("codeEnvName") - return bound, plugin.update_code_env() if bound else None + return plugin, plugin.get_settings().get_raw().get("codeEnvName") + + try: + plugin, bound = await run_blocking(_inspect) + if not bound: + return { + "status": "skipped", + "reason": "No code environment is bound to this plugin.", + "hint": ( + "Call ensure_plugin_code_env to check whether the plugin needs an " + "environment and create or recover its managed environment." + ), + } + future = await run_blocking(plugin.update_code_env) + timed_out, state = await _wait_for_future(future, timeout_seconds) + if timed_out: + return { + "status": "still_running", + "code_env_name": bound, + "future_id": future.job_id, + "hint": _FOLLOW_HINT, + } + result = _terminal_result("rebuild_code_env", plugin_id, state) + return _build_outcome(result, bound) + except Exception: + future_id = future.job_id if future is not None else None + return omit_empty( + { + "status": "unknown", + "code_env_name": bound, + "future_id": future_id, + "error": "The plugin update completed, but its code-environment rebuild could not be verified.", + "hint": ( + "Do not repeat update_plugin. " + + ( + "Check this future_id with get_future_status before taking further action." + if future_id + else "Inspect the plugin's bound environment in Dataiku before rebuilding; a rebuild may already have started." + ) + ), + } + ) + + +def _managed_env_name(plugin_id: str, env_name: str) -> bool: + return bool( + re.fullmatch( + re.escape(f"plugin_{plugin_id}_managed") + r"(?:_[0-9]+)?", env_name + ) + ) - bound, future = await run_blocking(_start) - if not bound: + +def _managed_env_names(client, plugin_id: str) -> list[str]: + return sorted( + raw["envName"] + for raw in client.list_code_envs() + if raw.get("deploymentMode") == "PLUGIN_MANAGED" + and _managed_env_name(plugin_id, raw.get("envName", "")) + ) + + +def _build_owner_key(client, future_id: str) -> tuple: + return (request.get_request_owner(), client.host, future_id) + + +def _resume_build( + client, plugin_id: str, future_id: str, retained_owner: str | None = None +) -> DSSFuture: + """Verify this plugin's build before reading its result. + + Dataiku forgets a finished future once its result is read, so peek first. + """ + future = DSSFuture(client, future_id) + try: + state = future.peek_state() + except DataikuException as exc: + if dataiku_message(exc) != "No future by that id": + raise + raise ValueError( + f"Dataiku has no future '{future.job_id}'; its result was already read. " + "Bind the environment with code_env_name." + ) from None + payload = state.get("payload") or {} + target = str(payload.get("displayName", "")).rpartition(": ")[2] + identified = _managed_env_name(plugin_id, target) or ( + payload.get("displayName") == "Import env" and retained_owner == plugin_id + ) + if payload.get("action") != "import_env" or not identified: + raise ValueError( + f"Future '{future.job_id}' is not a code environment build of plugin " + f"'{plugin_id}'." + ) + + future.get_state() + return future + + +def _start_build(plugin, plugin_id: str, python_interpreter: str | None): + """Start the managed-env build, or return None when the plugin declares none.""" + try: + return plugin.create_code_env(python_interpreter=python_interpreter) + except DataikuException as exc: + # The SDK may omit the error code, leaving only the endpoint's message. + message = dataiku_message(exc) + if re.search(r"\bERR_PLUGIN_WITHOUT_CODEENV\b", message) or message == ( + f"The plugin {plugin_id} doesn't specify a code env, cannot create it" + ): + return None + raise RuntimeError( + f"Dataiku refused plugin code-environment creation: {message}" + ) from None + + +async def _bind_code_env(plugin, env_name: str, build: dict, created: bool) -> dict: + """Bind an environment unless another binding appeared meanwhile, and report.""" + + def _bind(): + settings = plugin.get_settings() + current = settings.get_raw().get("codeEnvName") + if current and current != env_name: + return current + settings.set_code_env(env_name) + settings.save() + return None + + try: + changed = await run_blocking(_bind) + except DataikuException as exc: return { - "status": "skipped", - "reason": "No code environment is bound to this plugin.", - "hint": ( - "Check the plugin requirements and set up its code environment in the " - "Dataiku UI if needed. An unbound environment does not establish " - "whether the plugin needs one." - ), + "status": "binding_failed", + "code_env_name": env_name, + "build": build, + "error": dataiku_message(exc), + "hint": "Fix the cause, then retry with this code_env_name.", } - timed_out, state = await _wait_for_future(future, timeout_seconds) - if timed_out: + except RequestException: return { - "status": "still_running", - "code_env_name": bound, - "future_id": future.job_id, - "hint": _FOLLOW_HINT, + "status": "binding_unknown", + "code_env_name": env_name, + "build": build, + "hint": "The binding request lost its connection. Inspect the plugin binding, then retry with this code_env_name if needed.", } - result = _terminal_result("rebuild_code_env", plugin_id, state) - return _build_outcome(result, bound) + if changed: + return { + "status": "binding_changed", + "code_env_name": env_name, + "bound_code_env_name": changed, + "build": build, + } + return { + "status": "completed", + "created": created, + "code_env_name": env_name, + "build": build, + } + + +@mcp.tool( + meta=ADMIN_POLICY, + title="Ensure Plugin Code Environment", + description="Create and bind a plugin-managed code environment, resume its build, or bind an existing unbound one.", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "openWorldHint": False, + }, +) +async def ensure_plugin_code_env( + plugin_id: str, + ctx: Context, + python_interpreter: Annotated[ + PythonInterpreter, + Field( + description="Omit to let Dataiku select an interpreter based on the plugin's " + "accepted versions and server availability. Only applies to a new environment." + ), + ] = None, + future_id: str | None = None, + code_env_name: str | None = None, + timeout_seconds: int = _BUILD_TIMEOUT_SECONDS, +) -> str: + """Create and bind a plugin's managed code environment, or finish an interrupted setup. + + Args: + plugin_id: Installed plugin id. + python_interpreter: Omit to let Dataiku select an interpreter based on the + plugin's accepted versions and server availability. Only applies to a + new environment. + future_id: Resume a build this tool returned as still_running. Dataiku + forgets a build once its result is read, so this works only while it runs. + code_env_name: Bind this existing, unbound PLUGIN_MANAGED environment of the + plugin instead of creating one, as listed by recovery_required. + timeout_seconds: Inline wait budget for a build, checked between polls. + """ + plugin_id = _require_non_empty_string(plugin_id, "plugin_id") + timeout_seconds = _require_int_in_range( + timeout_seconds, "timeout_seconds", 1, _MAX_TIMEOUT_SECONDS + ) + if future_id is not None: + future_id = _require_non_empty_string(future_id, "future_id") + if code_env_name is not None: + code_env_name = _require_non_empty_string(code_env_name, "code_env_name") + if ( + sum( + value is not None + for value in (python_interpreter, future_id, code_env_name) + ) + > 1 + ): + raise ValueError( + "Pass at most one of python_interpreter, future_id, and code_env_name." + ) + await ctx.info(f"Preparing the code environment of plugin '{plugin_id}'...") + + def _inspect(): + client = get_dss_client() + _installed_plugin(client, plugin_id) + plugin = client.get_plugin(plugin_id) + return client, plugin, plugin.get_settings().get_raw().get("codeEnvName") + + client, plugin, bound = await run_blocking(_inspect) + common = {"operation": "ensure_plugin_code_env", "plugin_id": plugin_id} + if future_id: + future = await run_blocking( + _resume_build, + client, + plugin_id, + future_id, + _BUILD_OWNERS.get(_build_owner_key(client, future_id)), + ) + elif bound: + if python_interpreter or code_env_name not in (None, bound): + raise ValueError( + f"Plugin '{plugin_id}' is already bound to '{bound}'; nothing was " + "changed." + ) + return compact_json( + { + **common, + "status": "completed", + "created": False, + "code_env_name": bound, + "build": {"status": "unknown"}, + } + ) + else: + candidates = await run_blocking(_managed_env_names, client, plugin_id) + if code_env_name: + if code_env_name not in candidates: + raise ValueError( + f"'{code_env_name}' is not an existing managed environment of " + f"plugin '{plugin_id}'. Candidates: {candidates}." + ) + bound_env = await _bind_code_env( + plugin, code_env_name, {"status": "unknown"}, created=False + ) + return compact_json({**common, **bound_env}) + # An unbound environment may still be building, so neither guess nor + # create a duplicate. + if candidates: + return compact_json( + { + **common, + "status": "recovery_required", + "code_env_names": candidates, + "hint": "Resume its future_id if still building; otherwise bind one with code_env_name.", + } + ) + future = await run_blocking(_start_build, plugin, plugin_id, python_interpreter) + if future is None: + return compact_json( + { + **common, + "status": "not_required", + } + ) + + if future.job_id: + if len(_BUILD_OWNERS) >= 256: + _BUILD_OWNERS.pop(next(iter(_BUILD_OWNERS))) + _BUILD_OWNERS[_build_owner_key(client, future.job_id)] = plugin_id + + timed_out, state = await _wait_for_future(future, timeout_seconds) + if timed_out: + return compact_json( + { + **_in_flight( + "ensure_plugin_code_env", plugin_id, future, started=False + ), + "hint": "Call ensure_plugin_code_env again with this future_id; do not start another build.", + } + ) + result = _terminal_result("ensure_plugin_code_env", plugin_id, state) + _BUILD_OWNERS.pop(_build_owner_key(client, future.job_id), None) + env_name = _require_non_empty_string( + str(result.get("envName", "")), "created code environment name" + ) + if not _managed_env_name(plugin_id, env_name): + raise ValueError( + "The build returned another plugin's environment; no binding was changed." + ) + build = _build_outcome(result, env_name) + return compact_json( + {**common, **await _bind_code_env(plugin, env_name, build, created=True)} + ) def _unresolved_components_refusal(plugin_id: str, reason: str) -> dict: @@ -729,6 +1274,7 @@ def _unresolved_components_refusal(plugin_id: str, reason: str) -> dict: @mcp.tool( + meta=ADMIN_POLICY, title="Delete Plugin", description="Remove an installed plugin after checking whether its components are in use.", annotations={ diff --git a/dataiku_mcp/tools/project_folders.py b/dataiku_mcp/tools/project_folders.py index 0408fd1f..27f6b970 100644 --- a/dataiku_mcp/tools/project_folders.py +++ b/dataiku_mcp/tools/project_folders.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -61,6 +62,7 @@ def _walk_project_folders(client, folder_id: str) -> list[dict]: @mcp.tool( + meta=ADMIN_POLICY, title="List Project Folders", annotations={ "readOnlyHint": True, @@ -95,6 +97,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Get Project Folder", annotations={ "readOnlyHint": True, @@ -140,6 +143,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Create Project Folder", annotations={ "readOnlyHint": False, @@ -179,6 +183,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Move Project to Folder", annotations={ "readOnlyHint": False, @@ -214,6 +219,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Delete Project Folder", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/project_libraries.py b/dataiku_mcp/tools/project_libraries.py index 7930f441..44a72922 100644 --- a/dataiku_mcp/tools/project_libraries.py +++ b/dataiku_mcp/tools/project_libraries.py @@ -31,6 +31,10 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ( + PROJECT_EDIT_POLICY, + PROJECT_READ_POLICY, +) from ..config import request from ..server import mcp from ..executors import run_blocking @@ -260,6 +264,7 @@ def _ast_validate_python(content: str) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Project Library", annotations={ "readOnlyHint": True, @@ -344,6 +349,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Read Project Library File", annotations={ "readOnlyHint": True, @@ -377,6 +383,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Search Project Library", annotations={ "readOnlyHint": True, @@ -504,6 +511,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Validate Project Library File", annotations={ "readOnlyHint": True, @@ -555,6 +563,7 @@ def _read(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Write Project Library File", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/projects.py b/dataiku_mcp/tools/projects.py index 26058c8f..dd68dece 100644 --- a/dataiku_mcp/tools/projects.py +++ b/dataiku_mcp/tools/projects.py @@ -19,6 +19,11 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ( + ADMIN_POLICY, + PROJECT_EDIT_POLICY, + PROJECT_READ_POLICY, +) from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -140,6 +145,7 @@ def _normalize_code_env_settings(settings: dict, patch: dict) -> None: @mcp.tool( + meta=ADMIN_POLICY, title="Count Projects", annotations={ "readOnlyHint": True, @@ -155,6 +161,7 @@ async def count_projects(ctx: Context) -> str: @mcp.tool( + meta=ADMIN_POLICY, title="List Projects", annotations={ "readOnlyHint": True, @@ -218,6 +225,7 @@ async def list_projects( @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Create Project", annotations={ "readOnlyHint": False, @@ -274,6 +282,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Project Metadata", annotations={ "readOnlyHint": True, @@ -292,6 +301,7 @@ async def get_project_metadata(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Project Variables", annotations={ "readOnlyHint": True, @@ -310,6 +320,7 @@ async def get_project_variables(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Project Settings", annotations={ "readOnlyHint": True, @@ -330,6 +341,7 @@ def _run(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Update Project Settings", annotations={ "readOnlyHint": False, @@ -369,6 +381,7 @@ def _run(): @mcp.tool( + meta=PROJECT_EDIT_POLICY, title="Set Project Variables", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/recipes.py b/dataiku_mcp/tools/recipes.py index 88caadbe..828d0a19 100644 --- a/dataiku_mcp/tools/recipes.py +++ b/dataiku_mcp/tools/recipes.py @@ -20,6 +20,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -112,6 +113,7 @@ def _settings_view(settings, include_engine_params: bool = False) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Recipes", annotations={ "readOnlyHint": True, @@ -137,6 +139,7 @@ async def list_recipes(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Recipe Settings", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/scenarios.py b/dataiku_mcp/tools/scenarios.py index da455598..171bfd61 100644 --- a/dataiku_mcp/tools/scenarios.py +++ b/dataiku_mcp/tools/scenarios.py @@ -21,6 +21,11 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ( + ADMIN_POLICY, + PROJECT_EXECUTION_POLICY, + PROJECT_READ_POLICY, +) from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -91,6 +96,7 @@ async def _wait_for_scenario_run_result(trigger_fire, timeout_seconds: int): @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Scenarios", annotations={ "readOnlyHint": True, @@ -129,6 +135,7 @@ async def list_scenarios(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Scenario Settings", annotations={ "readOnlyHint": True, @@ -159,6 +166,7 @@ async def get_scenario_settings( @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Run Scenario", annotations={ "readOnlyHint": False, @@ -303,6 +311,7 @@ def _poll_failed_payload(run_id, error) -> dict: @mcp.tool( + meta=PROJECT_EXECUTION_POLICY, title="Get Scenario Run History", annotations={ "readOnlyHint": True, @@ -376,6 +385,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="List Messaging Channels", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/semantic_models.py b/dataiku_mcp/tools/semantic_models.py index 22242415..097ba13e 100644 --- a/dataiku_mcp/tools/semantic_models.py +++ b/dataiku_mcp/tools/semantic_models.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -24,6 +25,7 @@ @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Semantic Models", annotations={ "readOnlyHint": True, @@ -61,6 +63,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Semantic Model Version Settings", annotations={ "readOnlyHint": True, @@ -68,7 +71,7 @@ def _run(): "openWorldHint": False, }, ) -async def get_semantic_model_version_settings( +async def get_semantic_model_version_config( project_key: str, semantic_model_id: str, version_id: str, diff --git a/dataiku_mcp/tools/users.py b/dataiku_mcp/tools/users.py index 056d4ddb..14ae44f0 100644 --- a/dataiku_mcp/tools/users.py +++ b/dataiku_mcp/tools/users.py @@ -19,6 +19,7 @@ from fastmcp import Context from pydantic import Field +from ..instance_policy import ADMIN_POLICY from ..server import mcp from ..auth import get_dss_client, require_admin from ..executors import run_blocking @@ -106,6 +107,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="List Users", annotations={ "readOnlyHint": True, @@ -193,6 +195,7 @@ async def list_users( @mcp.tool( + meta=ADMIN_POLICY, title="Create User", annotations={ "readOnlyHint": False, @@ -247,6 +250,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Update User", annotations={ "readOnlyHint": False, @@ -329,6 +333,7 @@ def _run(): @mcp.tool( + meta=ADMIN_POLICY, title="Delete User", annotations={ "readOnlyHint": False, diff --git a/dataiku_mcp/tools/utils/metrics.py b/dataiku_mcp/tools/utils/metrics.py index c18375f1..e88f8bea 100644 --- a/dataiku_mcp/tools/utils/metrics.py +++ b/dataiku_mcp/tools/utils/metrics.py @@ -64,3 +64,47 @@ def select_metrics( if missing_metric_ids: result["missing_metric_ids"] = missing_metric_ids return result + + +def compute_report_errors(report: dict | None) -> list[str]: + """List the engine failures carried by a metric computation report. + + DSS reports a failed probe engine inside the report instead of raising, so a + caller that ignores it reads the previous stored value as if it were fresh. + """ + runs = ((report or {}).get("result") or {}).get("runs") or [] + return [ + f"{run.get('engine', 'unknown engine')}: " + f"{(run.get('error') or {}).get('message', 'computation failed')}" + for run in runs + if run.get("error") + ] + + +def unrefreshed_metric_ids(report: dict | None, metric_ids: list[str]) -> list[str]: + """Ids not actually computed, including stored values from disabled probes.""" + computed = ((report or {}).get("result") or {}).get("computed") or [] + computed_ids = {metric.get("metricId") for metric in computed} + return [metric_id for metric_id in metric_ids if metric_id not in computed_ids] + + +def stale_metric_ids( + raw_metrics: list[dict[str, Any]], last_build_start: int | None +) -> list[str]: + """Ids of metrics whose newest value was computed before the last build started. + + Compute-on-build metrics are recorded during the build, so the build start (not + its end) separates values that describe the current data from older ones. + """ + if not last_build_start: + return [] + stale = [] + for metric in raw_metrics: + computed = [ + value["computed"] + for value in metric.get("lastValues", []) + if isinstance(value.get("computed"), int) + ] + if computed and max(computed) < last_build_start: + stale.append(metric.get("metric", {}).get("id")) + return stale diff --git a/dataiku_mcp/tools/utils/validation.py b/dataiku_mcp/tools/utils/validation.py index 91254011..99a3cf62 100644 --- a/dataiku_mcp/tools/utils/validation.py +++ b/dataiku_mcp/tools/utils/validation.py @@ -15,6 +15,8 @@ """Shared validation helpers for tool modules.""" import re +from pathlib import PurePosixPath +from urllib.parse import urlsplit _HEX_COLOR_PATTERN = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") @@ -109,3 +111,39 @@ def require_hex_color(value: str, field_name: str) -> str: f"'{field_name}' must be a hex color in '#RGB' or '#RRGGBB' format" ) return cleaned + + +def require_git_repository_url(value: str) -> str: + """Accept remote Git URLs without inline secrets or local filesystem access.""" + value = require_non_empty_string(value, "repository_url") + if any(character.isspace() for character in value): + raise ValueError("'repository_url' must not contain whitespace") + if re.fullmatch(r"[\w.][\w.-]*@[\w.-]+:[^?#]+", value): + return value + try: + parsed = urlsplit(value) + valid = ( + parsed.scheme in {"https", "http", "ssh", "git"} + and parsed.hostname + and parsed.path not in {"", "/"} + and not parsed.query + and not parsed.fragment + and parsed.password is None + and (parsed.username is None or parsed.scheme == "ssh") + ) + except ValueError: + valid = False + if not valid: + raise ValueError( + "'repository_url' must be a remote Git URL without passwords, tokens, " + "query parameters, or fragments. Configure credentials in Dataiku." + ) + return value + + +def require_relative_posix_path(value: str, field_name: str) -> str: + value = require_non_empty_string(value, field_name) + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or "\\" in value: + raise ValueError(f"'{field_name}' must be a relative path without '..'") + return value diff --git a/dataiku_mcp/tools/webapps.py b/dataiku_mcp/tools/webapps.py index 83232542..e6f22616 100644 --- a/dataiku_mcp/tools/webapps.py +++ b/dataiku_mcp/tools/webapps.py @@ -18,6 +18,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -53,6 +54,7 @@ def _serialize_webapp_list_item(item: dict) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Webapps", annotations={ "readOnlyHint": True, @@ -90,6 +92,7 @@ async def list_webapps(project_key: str, ctx: Context) -> str: @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Webapp Settings", annotations={ "readOnlyHint": True, @@ -120,6 +123,7 @@ async def get_webapp_settings( @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Webapp State", annotations={ "readOnlyHint": True, diff --git a/dataiku_mcp/tools/wikis.py b/dataiku_mcp/tools/wikis.py index 414f7f68..1c2572fc 100644 --- a/dataiku_mcp/tools/wikis.py +++ b/dataiku_mcp/tools/wikis.py @@ -16,6 +16,7 @@ from fastmcp import Context +from ..instance_policy import PROJECT_READ_POLICY from ..server import mcp from ..auth import get_dss_client from ..executors import run_blocking @@ -33,6 +34,7 @@ def _build_parent_map(taxonomy: list, parent_id: str | None = None) -> dict: @mcp.tool( + meta=PROJECT_READ_POLICY, title="List Wiki Articles", annotations={ "readOnlyHint": True, @@ -70,6 +72,7 @@ def _run(): @mcp.tool( + meta=PROJECT_READ_POLICY, title="Get Wiki Article", annotations={ "readOnlyHint": True, diff --git a/docs/capabilities.md b/docs/capabilities.md index 1e79feb5..e3eeef8c 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -22,17 +22,49 @@ Local profile actions are listed separately because they only change which Datai instance the local client targets. +## Instance types + +The configured instance type controls which tools can run in both stdio and HTTP. +The catalog stays the same; each tool advertises its allowed types in metadata, +and dispatch rejects unsupported calls before authentication delegation or execution. +Dataiku permissions and API availability still apply to permitted calls. + +| Instance type | Available tools | +|---|---| +| design | All existing explicit tools | +| automation | Reads, existing asset execution, instance administration, and project-folder operations; no Cobuild or project definition/content writes | +| agent-management, govern, deployer | Instance controls only; API tools for these node types are not implemented yet | + +Automation excludes all six Cobuild tools, even conversation listing and polling, +and `create_project`, `update_project_settings`, `set_project_variables`, +`set_container_exec_config`, `create_upload_dataset`, `create_managed_folder`, +`upload_file_to_managed_folder`, `write_project_library_file`. +It permits `build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, metric +recomputation through `get_dataset_metrics`, and every +project-folder operation, including creation, deletion, and moving projects. +Existing execution can modify outputs; this policy does not make Automation read-only. + +Instance controls are `list_instances`, `switch_instance`, `configure_instance`, +`delete_instance`, and `get_current_instance`. The first four also work without an +active selection, subject to the existing HTTP restrictions on local configuration. +For Design and Automation, a failed `get_current_instance` connection check returns +`connection_status=failed` and the exception text in the error field. +For Agent Management, Govern and Deployer, `get_current_instance` returns sanitized profile metadata +with connection_status set to unsupported, without contacting DSS or reporting a version. +Agent Management, Govern and Deployer profiles can be saved without a connection +test. Design and Automation require a successful DSS connection test in local setup. + ## Surface -**129 tools** · 93 read · 22 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**131 tools** · 93 read · 24 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 22 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 24 | Bootstrap, project configuration, cross-project, admin | | Cobuild conversation | 6 | All flow and analytic building | -| Execute | 4 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job` | +| Execute | 5 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job`, `get_dataset_metrics` (recomputes metrics before reading them) | | Local profile action | 3 | `configure_instance`, `switch_instance`, `delete_instance` | | Connection test | 1 | `test_connection` | @@ -60,8 +92,8 @@ empty managed folder, and select container execution for an existing object (see | Webapps | `list_webapps`, `get_webapp_settings`, `get_webapp_state` | `set_container_exec_config` | | Wikis | `list_wiki_articles`, `get_wiki_article` | — | | Data quality | `list_data_quality_rules`, `get_data_quality_status`, `get_data_quality_rule`, `get_data_quality_rule_results`, `get_data_quality_rule_history` | — | -| LLMs & knowledge banks | `list_llms`, `get_llm_info`, `list_knowledge_banks`, `get_knowledge_bank_settings`, `search_knowledge_bank`, `list_retrieval_augmented_llms`, `get_retrieval_augmented_llm_settings` | `set_container_exec_config` (knowledge banks) | -| Semantic models | `list_semantic_models`, `get_semantic_model_version_settings` | — | +| LLMs & knowledge banks | `list_llms`, `get_llm_info`, `list_knowledge_banks`, `get_knowledge_bank_settings`, `search_knowledge_bank`, `list_rag_llms`, `get_rag_llm_settings` | `set_container_exec_config` (knowledge banks) | +| Semantic models | `list_semantic_models`, `get_semantic_model_version_config` | — | | Evaluation stores | `list_evaluation_stores`, `get_evaluation_store_details` | — | ## Driving Cobuild @@ -88,7 +120,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Project libraries | `list_project_library`, `read_project_library_file`, `search_project_library`, `validate_project_library_file` | `write_project_library_file` | Bootstrap, **in-project** — local-file write is stdio only | | Project folders | `list_project_folders`, `get_project_folder` | `create_project_folder`, `move_project_to_folder`, `delete_project_folder` | Cross-project | | Code environments | `list_code_envs` | `create_code_env`, `update_code_env`, `delete_code_env` | Instance-level | -| Plugins | `list_plugins`, `list_plugin_usages` | `update_plugin`, `delete_plugin` | Instance-level — `update_plugin` is stdio only | +| Plugins | `list_plugins`, `list_plugin_usages` | `install_plugin`, `ensure_plugin_code_env`, `update_plugin`, `delete_plugin` | Instance-level — local-path install/update are stdio only | | Users | `list_users` | `create_user`, `update_user`, `delete_user` | Instance-level | | Groups | `list_groups` | `create_group`, `update_group`, `delete_group` | Instance-level | | Jobs | `list_jobs`, `get_job_status`, `get_job_log`, `get_future_status`, `wait_for_job` | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job` | Execution — re-runs or stops assets that already exist | @@ -99,6 +131,12 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. +The [plugin development guide](../skills/dataiku-headless/references/plugin-development.md) +explains how to write, test, and package plugins locally with the agent's file and +command tools. Headless can upload a package over stdio or install and update from Git over +either transport. It cannot create a +development plugin or edit its files on the instance. + ## Local instance targeting Local client configuration, not Dataiku objects. @@ -108,4 +146,4 @@ Local client configuration, not Dataiku objects. | `configure_instance` | Connect a local stdio instance; opens a local page for URL, instance type, and API key (disabled in HTTP mode) | | `switch_instance` | Change the active instance; in HTTP mode saves only the authenticated user’s selected catalog instance | | `list_instances`, `get_current_instance` | Show configured instances, their declared types, and the active one; `get_current_instance` includes the Dataiku version when available | -| `delete_instance` | Removes a **saved connection profile from the local config file**. Does not touch the Dataiku instance. | +| `delete_instance` | Removes a **saved instance profile from the local config file**. Does not touch the Dataiku instance. | diff --git a/docs/http-deployment.md b/docs/http-deployment.md index e0847a77..04f96643 100644 --- a/docs/http-deployment.md +++ b/docs/http-deployment.md @@ -110,9 +110,11 @@ The settings file has four sections: - `user_selections` records each authenticated user's current instance. Start with an empty object; the server manages it. -Instance types are operator-supplied metadata reported by instance tools. They do -not trigger API discovery or change client selection, connection testing, or tool -availability. +Instance types are operator-supplied and enforce the same +[tool policy](capabilities.md#instance-types) as stdio. Unsupported tool calls are +rejected before DSS token exchange. Agent Management, Govern and Deployer support instance controls +only; `get_current_instance` reports `unsupported` without contacting DSS. The +configured type does not trigger API discovery or select a different SDK client. Authentication, transport, and instance settings are loaded at startup, so restart the server after changing them. User selections are updated in memory and persisted @@ -125,7 +127,7 @@ select an entry, and Dataiku decides what that user may do. `configure_instance` ## Distribute the interactive OAuth plugin -The public `dataiku-headless` marketplace plugin starts its own stdio server and +The public `headless` marketplace plugin starts its own stdio server and must not be installed for this mode. 1. Render the customer-specific plugin with the deployed server's public MCP URL: @@ -139,8 +141,18 @@ uv run python scripts/build_http_plugin.py \ 2. Add the rendered directory to the organization's enterprise/private marketplace or normal plugin distribution channel. Add `--zip` to the command to create `./dataiku-headless-http.zip` when that channel accepts ZIP artifacts. -3. Users install `dataiku-headless-http`. Its setup skill completes OAuth, chooses - an administrator-managed instance, and verifies access. +3. Users install the rendered `headless-http` plugin. Its setup skill completes + OAuth, chooses an administrator-managed instance, and verifies access. + +The ZIP keeps its `dataiku-headless-http/` root. The plugin identifier is +`headless-http`, distinct from the public stdio plugin's `headless` identifier. +The HTTP server key is `dku` in every manifest to fit Bedrock's tool-name limit. + +The rendered catalog maps `dataiku-headless-http` to `headless-http`. Publish it +in the same private marketplace, then have Claude Code users refresh that +marketplace and run `/plugin install headless-http@` once. +Administrators must update managed settings. Other clients should replace the +old plugin installation. Authenticate the `dku` server when prompted. The renderer does not modify this stdio-first source checkout or copy Dataiku credentials, OAuth secrets, or the HTTP deployment configuration. Keep only one diff --git a/pyproject.toml b/pyproject.toml index adf47e61..be3d5e0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dataiku-headless" -version = "0.8.0" +version = "0.9.0" description = "Headless Dataiku agent kit: a FastMCP server for operating Dataiku from an AI agent harness. The companion agent skill library ships via the Claude Code / Codex / Cortex Code plugin, not this package." readme = "README.md" requires-python = ">=3.10" @@ -25,6 +25,8 @@ dependencies = [ "pydantic>=2.11,<3", "python-dotenv", "requests>=2.32", + "six>=1.17,<2", + "truststore>=0.10.5,<0.11", ] [project.urls] @@ -70,6 +72,7 @@ update_changelog_on_bump = true major_version_zero = true # Keep the plugin manifests' "version" field in lockstep with [project].version. version_files = [ + ".github/plugin/plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cortex-plugin/plugin.json", diff --git a/runtime/run_mcp.py b/runtime/run_mcp.py index 6093e050..d829f488 100755 --- a/runtime/run_mcp.py +++ b/runtime/run_mcp.py @@ -7,6 +7,8 @@ # "pydantic==2.13.4", # "python-dotenv==1.2.2", # "requests==2.34.2", +# "six==1.17.0", +# "truststore==0.10.5", # ] # /// @@ -77,6 +79,12 @@ def main() -> None: load_dotenv(REPO_ROOT / ".env", override=False) + # Activate system CA trust before importing any networking libraries. + # Keep this application-wide SSL patch out of package imports. + import truststore + + truststore.inject_into_ssl() + from dataiku_mcp import run_http_server, run_stdio_server if args.transport == "stdio": diff --git a/runtime/run_mcp.py.lock b/runtime/run_mcp.py.lock index 725cbfd4..7af55537 100644 --- a/runtime/run_mcp.py.lock +++ b/runtime/run_mcp.py.lock @@ -5,9 +5,8 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] [manifest] @@ -17,6 +16,8 @@ requirements = [ { name = "pydantic", specifier = "==2.13.4" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, + { name = "six", specifier = "==1.17.0" }, + { name = "truststore", specifier = "==0.10.5" }, ] [[package]] @@ -24,8 +25,7 @@ name = "aiofile" version = "3.8.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] dependencies = [ { name = "caio", version = "0.9.25", source = { registry = "https://pypi.org/simple" } }, @@ -46,7 +46,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ - { name = "caio", version = "0.12.9", source = { registry = "https://pypi.org/simple" } }, + { name = "caio", version = "0.12.10", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } wheels = [ @@ -64,16 +64,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.15.1" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/98/7e457a3b014976887a050561955c0b76e1e7e2d6e27b78ecef2ef80518f2/anyio-4.16.0.tar.gz", hash = "sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb", size = 310971, upload-time = "2026-11-01T11:20:07.282Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/a9/16/7757fb84c819accc7d7e9af5291b261e4abb8627ee040f99e7d35ef2457a/anyio-4.16.0-py3-none-any.whl", hash = "sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026", size = 134521, upload-time = "2026-11-01T11:20:05.59Z" }, ] [[package]] @@ -87,15 +87,16 @@ wheels = [ [[package]] name = "authlib" -version = "1.8.0" +version = "1.9.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "joserfc" }, + { name = "joserfc", version = "1.7.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "joserfc", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/96/6e7ed72bb49762a9b65d51c4cb235d793f0485cd8d6f7e9404ce955a4808/authlib-1.9.5.tar.gz", hash = "sha256:3f031d5e9b6486089e91e8f7f1b68f36275f3151c66460d650fc90d420545fd7", size = 183564, upload-time = "2026-11-05T16:13:18.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, + { url = "https://files.pythonhosted.org/packages/58/d7/d384e7a639dd1d018767ba0fe39601b61cded066bb7af38db31ec998252d/authlib-1.9.5-py2.py3-none-any.whl", hash = "sha256:e32a63e3d869196b61816b2602231862179a2ffc29358c009d1f022c8b86a8b2", size = 267074, upload-time = "2026-11-05T16:13:16.766Z" }, ] [[package]] @@ -109,20 +110,20 @@ wheels = [ [[package]] name = "beartype" -version = "0.22.9" +version = "0.23.1" 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", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/34/c0cfdaee9c796c8381439fed0fcab62810753b8b0adb1ec88fa7f41eaa5d/beartype-0.23.1.tar.gz", hash = "sha256:8b805f246b32931c74f2b321ea0939fe5bb2ff43c12a3eff19cc4c854b832e1d", size = 1993714, upload-time = "2026-10-11T05:36:55.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/2f6aa982e2860c1156830db3da7f823b71784ed7d533ca289ea9b6c6347a/beartype-0.23.1-py3-none-any.whl", hash = "sha256:4461b4dc57e3fdd6c8a8464b22cf118002eea107c33e9fef651c69068ab3cce3", size = 1582101, upload-time = "2026-10-11T05:36:52.431Z" }, ] [[package]] name = "cachetools" -version = "7.2.1" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/44/71476a5812da1ddf2c9a3efd31ae76d01480a1cf03ed13ac28aa8f2402e4/cachetools-7.2.1.tar.gz", hash = "sha256:b1a7537025c06abf96fcc1443e496af9a3fb95e774e70e1f0af226f73f7f2dcc", size = 41357, upload-time = "2026-10-05T18:40:06.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/6b/6c1553e1e8f672f9423344a1915aad1537439ad3d14a3fbc1a02159cfd94/cachetools-7.3.0.tar.gz", hash = "sha256:3f63fc208410f0c3a3b37883c4903ecdacd6f0b259134e6d2e82debd5b2e01de", size = 48563, upload-time = "2026-11-05T20:38:46.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/c9/2a61d784caf0d869a3326728c57c7203f50cc53f3cca2ee76bf924769eb4/cachetools-7.2.1-py3-none-any.whl", hash = "sha256:63aa53dfe7473c10cccdd5a01dedf76ef2c4b73a58840d9396e7d0752cbdac3b", size = 17006, upload-time = "2026-10-05T18:40:04.827Z" }, + { url = "https://files.pythonhosted.org/packages/29/aa/e54a49d0927b2fa7b86bc94dbde57125e38e8cfca7a31b6764e8dbf35e9f/cachetools-7.3.0-py3-none-any.whl", hash = "sha256:2105787c94619bde7072624fec0fe7b8a08bb7bf6c757c318f8925573c6f07c9", size = 19575, upload-time = "2026-11-05T20:38:44.928Z" }, ] [[package]] @@ -130,8 +131,7 @@ name = "caio" version = "0.9.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } wheels = [ @@ -160,7 +160,7 @@ wheels = [ [[package]] name = "caio" -version = "0.12.9" +version = "0.12.10" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -168,59 +168,59 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/56/51/bd8b64bf700f5b1a956a60bb62276b79a094e8cd0ddc60b1b61c3edd496f/caio-0.12.9.tar.gz", hash = "sha256:99e99419b44ab5511f7468c6a452887dd125b8e4042672a7589f0cf01d254ea8", size = 84084, upload-time = "2026-09-26T09:51:49.433Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/dd/eb01403e589183dd336d74ae68fc91d52cc100374fd0d38883f47e5a6572/caio-0.12.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:98e88c20217456113ae3021882aa310fab8ae77e54ec62a7c990f1c34a9f62a6", size = 41959, upload-time = "2026-09-26T09:50:49.043Z" }, - { url = "https://files.pythonhosted.org/packages/ff/51/7e54415ca0b79dc9d044fcaea3439b5590115ca9a9452ad27e97db5ecc22/caio-0.12.9-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:0b9922e7d5800b9aadafbe8ba956ed095ac37f85e0b38b96c97586e443548ff5", size = 160089, upload-time = "2026-09-26T09:50:50.935Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2e/4d5a60fb0fdde3da36317f4e416e986a3a6a625a4013c00a2b6584497191/caio-0.12.9-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c0432568cdf7cd36868f5d9800dfbb840d0ffaf0e8f2df0b42fbcbf83f2353ab", size = 156597, upload-time = "2026-09-26T09:50:52.233Z" }, - { url = "https://files.pythonhosted.org/packages/12/2e/a5137a9bb4899b282a6e32592403036f5d56e4140476f5b18787e0b5d129/caio-0.12.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17ec3d3cb28878e4771445f73161677a964f833f091fc88634a7214013dd8ed5", size = 157604, upload-time = "2026-09-26T09:50:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/8b/59/3d6a703334303c22aae055e06cd7ce50ab05135456f10df38884a0de6cc7/caio-0.12.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3bf16c16b9318498ee7669a495d2198c44abdc0bc64483a500682c4eb41d5b52", size = 156269, upload-time = "2026-09-26T09:50:55.031Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/648c2d37a805528371934f649cb1070477b20d06b4bda8a1cef3ec45f2dc/caio-0.12.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4edc95d8dc49f91fc9e870174860729fd088f9529464a7a6a1deda8a14d59a4f", size = 48876, upload-time = "2026-09-26T09:50:56.322Z" }, - { url = "https://files.pythonhosted.org/packages/24/93/1951d8cf56fdfbb4f28c4073f5a732b7863a01c2ad6a7574792726b9ed00/caio-0.12.9-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:14483697a27aefd265decb4b595e70814d284f46bc352c75eb9e9dae94a7b398", size = 162790, upload-time = "2026-09-26T09:50:57.835Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/2878af4bfaca5a25dc573afaf18d32dd32612fbde7ec5a3e51d69786b11f/caio-0.12.9-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:e10e3be34fca464cc5e27d3011e284fd0cde0cc488cda4742e765c8e9291647d", size = 159007, upload-time = "2026-09-26T09:50:59.091Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/21be4805d108c21c89c205d48331d8d509116998ef39a80cbc6b40e5af62/caio-0.12.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:25255807af48386c50a2b186eaa582ffd5347d67c4f1cb3aa1bb79b36ace7af9", size = 160314, upload-time = "2026-09-26T09:51:00.54Z" }, - { url = "https://files.pythonhosted.org/packages/77/65/1cd722b6f288ebe04d6fd85c8a5595f2b350b3070a15390566970e2eb7cd/caio-0.12.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:477d66e16948845d0f5ec6535e84c0c9ab7420b2f78f81bcf7b0f81d18a34ffb", size = 158682, upload-time = "2026-09-26T09:51:01.908Z" }, - { url = "https://files.pythonhosted.org/packages/08/36/4cf8f5a6fd0ea7234107e5ab38ca5e0058beae7477c24768a6ab51b79e0a/caio-0.12.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:554c6ee883a0f1e95236d85b8e7d8187b212668855ac3a858acb80f5e2e82e82", size = 49041, upload-time = "2026-09-26T09:51:03.37Z" }, - { url = "https://files.pythonhosted.org/packages/94/31/3b6e9d644f9337ac6823f25b3cce838569d4b27ac9a3f9a371369f3d15d5/caio-0.12.9-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:6e72fb0ddd369f712a4ad229ee0f1d7df6852e9b59b36fe8a46210b4c9ea8e82", size = 164686, upload-time = "2026-09-26T09:51:04.88Z" }, - { url = "https://files.pythonhosted.org/packages/71/f7/1894b1ca1fae9317b8b8203f999107e927ca9badafbe0f7a294952fe00cb/caio-0.12.9-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f0698976f84dd40024204f0f77cf59ed7446e989575c8ad8cb64cd4b3e2871f3", size = 162375, upload-time = "2026-09-26T09:51:06.448Z" }, - { url = "https://files.pythonhosted.org/packages/21/ce/040af704ea23a6f4383685d05068ec59301e3bb12cc2ca7c664d16a82691/caio-0.12.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a06afa038f76324f439595b7a3242abf090a80b03006ed3d7dbbb9c031baf8a3", size = 162189, upload-time = "2026-09-26T09:51:07.862Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/d94003593fee5b76d725e5cc51e902439ee3491c6bd4331b232b2ebf12aa/caio-0.12.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed343077d8178c003b1596f40b1f46955be963a2871a1f85519be823113b24b0", size = 161976, upload-time = "2026-09-26T09:51:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/e8/bf/ed21d62204e4feece79985f45d2ebce6949c9e3d320e9f9e9638e9dc980a/caio-0.12.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ed23f6ad6897fae4c02e3e8d236aa3cc18e766a7c5bb0352ea5e9fb26c5d3335", size = 49024, upload-time = "2026-09-26T09:51:10.472Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c6/fea92dd83cdf28ee20d403a910e194fa5e1a97215a00e5bbbeb699ac582a/caio-0.12.9-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:c327977b8174337c1aaff27da17ac249d176ef8ea6e2dbf70b49cdd8038d57d3", size = 164835, upload-time = "2026-09-26T09:51:12.213Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0f/8ec37d3d6b47b8c6bd6c68a1d2d9b5de1ea05df59b601575b7003c9439e2/caio-0.12.9-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:f6ffb3d448016d20d8c40c53864d81bdf7463157969de65841eeb370519dbf78", size = 162567, upload-time = "2026-09-26T09:51:13.559Z" }, - { url = "https://files.pythonhosted.org/packages/ec/5f/b3258150ea0e87f032859df825dd0ec8e9f3a6c798addc6463cf1e4a2298/caio-0.12.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a11421fb4ac591e6fea5512d9a8ad1b488e7b28cf610ede973bbfb4be5177454", size = 162299, upload-time = "2026-09-26T09:51:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6e/5712cbf5168fdb4c65c44b0435daeeac7c00609cdc1fd3414812691fbb68/caio-0.12.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3f6dc05486ce4e1027f1d2da4d84c1d6bb815886e76f34a9228d370d86a5537", size = 162155, upload-time = "2026-09-26T09:51:16.395Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/5a08b26fae45320c8d6f3b42e6225c5316b39f6cea811ba3f890315b4641/caio-0.12.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c43eaebf220aeb9a388d309a5352e82562a7b9b2ea0102502204413641cfed7", size = 42777, upload-time = "2026-09-26T09:51:17.929Z" }, - { url = "https://files.pythonhosted.org/packages/44/fd/d84318ae7704b8c427584becaacfc3888fca8ec3fca25904ef479f57fa2a/caio-0.12.9-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:bc63db6b4a54b2f1c519424acfb2b7198664e4ef8427d397215a81c0bed9e9e6", size = 42008, upload-time = "2026-09-26T09:51:19.15Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f3/34487be50fbdc4cc809bcbe82eac565376fdfa7c675ca02feca2f96cbc6c/caio-0.12.9-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:83718f0ba9ff56de9c3ce7a61b463466fbb064be4f087230064abac5d08b8100", size = 165343, upload-time = "2026-09-26T09:51:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f5/3baf870c5775c1bf2399d971d213d34a30750475010070a595df7f536f7d/caio-0.12.9-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4a69de19ef8780ea67f5fffa6fed95af32ed4c036e338a361307314306ac816c", size = 162599, upload-time = "2026-09-26T09:51:21.814Z" }, - { url = "https://files.pythonhosted.org/packages/93/1d/fbc0005d9aa44204f6d106707a3261c19e402ec64381217998df270cbbe5/caio-0.12.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d3d3664c757d59d330381666683cdfa287c5bfff90819868e98ef0bb8c2d2382", size = 162959, upload-time = "2026-09-26T09:51:23.489Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6d/d6274d9a4d637d484314456222c898988ec03c1895d3b9ec2fcefe3c24cd/caio-0.12.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e152e1a1970d49b6056c1c94547801fcc6b41fc12185db6628ed58ec7785b04", size = 162234, upload-time = "2026-09-26T09:51:24.977Z" }, - { url = "https://files.pythonhosted.org/packages/9e/fc/228e74a8408714cc2e274250055412c1abd02cce62371820ac9da99a2a1d/caio-0.12.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:32755af4459fed70d10a5e8c396de3ccb57e02365c7d818937a9794884b172da", size = 42780, upload-time = "2026-09-26T09:51:26.261Z" }, - { url = "https://files.pythonhosted.org/packages/47/20/d9d7ee48d7a3cc3211cb841aa78712676416099025c60cb9e316c321d100/caio-0.12.9-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:de4458707370b9f13de2ead07e6719305624b4f0aad665ce6cb2f1452eb4965d", size = 186873, upload-time = "2026-09-26T09:51:27.538Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a9/6fc366300809c09916ee9212711ee470ed5103d5d4895a843633a2ca1ad8/caio-0.12.9-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:0a571981c8724f69c34ed4c7619585d552d27fe38bad0daf970dca3e14d6922b", size = 182783, upload-time = "2026-09-26T09:51:28.993Z" }, - { url = "https://files.pythonhosted.org/packages/65/5d/900bb797d8e51d06b5f1aa691dc1f7bf08a01e6a67397c08f8b4c090e01b/caio-0.12.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1c043b15a19e33c0b18b1937493be6730c3b08acc33af6899f669ac8957a3e46", size = 185071, upload-time = "2026-09-26T09:51:30.424Z" }, - { url = "https://files.pythonhosted.org/packages/df/df/94e65a3e77c5cb1f7ce19714084fc1f19f86fa0a1db43c63edc39d6b38d9/caio-0.12.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dada2e1ca5481e5c11201d269ac8e009ce86d34478f4426e9739470b0c2a1030", size = 182583, upload-time = "2026-09-26T09:51:31.921Z" }, - { url = "https://files.pythonhosted.org/packages/c8/36/3ae6214f37413e1451b9fba72a05f5f9d64f9020db5445e2d73fa403e53e/caio-0.12.9-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:53d3febc3e46707786a023b28bd21213ac78332db46fae7df2a03c0cc208976d", size = 42019, upload-time = "2026-09-26T09:51:33.226Z" }, - { url = "https://files.pythonhosted.org/packages/c1/0d/9b6cc05b09f66dae9cbb1633ca3c2598496239d636b95e3690db3b2c27e7/caio-0.12.9-cp315-cp315-manylinux_2_34_aarch64.whl", hash = "sha256:6634c57de5883819e0fb423094fe5cb480e81b8f5f46f601eb143e2c762f4c14", size = 166496, upload-time = "2026-09-26T09:51:34.558Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/ebb69add3f15b4345323f778295b27988b653b191df54060c19a66ea44d5/caio-0.12.9-cp315-cp315-manylinux_2_34_x86_64.whl", hash = "sha256:aa0fe6b459ef45d9d1fe82e22d5d849dcdb07e3a87f56444d1ff799249a08576", size = 163522, upload-time = "2026-09-26T09:51:36Z" }, - { url = "https://files.pythonhosted.org/packages/01/60/aec76058124f8a5b1979351cdfc83757a752c6456c36a6bdd256b743360f/caio-0.12.9-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:7fee93281b220488a5e6949ac197a52c2f1e877c6e32aec5ce658e9add3eba60", size = 164136, upload-time = "2026-09-26T09:51:37.383Z" }, - { url = "https://files.pythonhosted.org/packages/a4/86/bd5b6553fa6bab7976bf60087c2a73a8546c5bf5499e805d51ea0c33016c/caio-0.12.9-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6c28c4789a9c6d8e8b36cac15f85727bd8e4d312c98fc5aa4fceaa4631669786", size = 163131, upload-time = "2026-09-26T09:51:38.868Z" }, - { url = "https://files.pythonhosted.org/packages/29/fa/2842996fdb0a906325c57bc8adf205be4556e33c7ad0d04eec80077ddc3a/caio-0.12.9-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e600822c774fdb434e43163c7fc0d4dcfaa6540ebbf77e82e5f0dac071b27f8f", size = 42775, upload-time = "2026-09-26T09:51:40.587Z" }, - { url = "https://files.pythonhosted.org/packages/f5/9e/080587a73689f5f0de33f8be75cbb2e35c822d50d7f957150bdcef38dd92/caio-0.12.9-cp315-cp315t-manylinux_2_34_aarch64.whl", hash = "sha256:cc30e0c458d2d6785e72bb5117086ffce61b5c058bee5637c5aea69042db5e86", size = 187497, upload-time = "2026-09-26T09:51:42.074Z" }, - { url = "https://files.pythonhosted.org/packages/49/80/950a557f05c492416e3d5b4ec40d7b6c330567b311141aafe69ecb86a063/caio-0.12.9-cp315-cp315t-manylinux_2_34_x86_64.whl", hash = "sha256:7490517a72f4ad01b39311ff8e909c3cab77f12dddd370ba6deb035313038468", size = 183529, upload-time = "2026-09-26T09:51:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/44b405e601c9662223b01b56a38e16920d9633a8897a223399efa791d0b3/caio-0.12.9-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b30f3f36e45afb0814fc828cbb10ef9dd91043ff0b0a14ffe1cd8a348b461179", size = 185758, upload-time = "2026-09-26T09:51:44.98Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8f/2b08f5e117e3663396cfd0d935cdb9218ad3c374d10911ec278fc0c6aee5/caio-0.12.9-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b17b9aca6360f72bf07f1062bd60d8849f3fd5c1cb77f43923d8dec7fdbbb5df", size = 183425, upload-time = "2026-09-26T09:51:46.402Z" }, - { url = "https://files.pythonhosted.org/packages/c0/99/96888ad510c9adf42bfbad9ba139831b58055fd61e1301f6268e9bb490b8/caio-0.12.9-py3-none-any.whl", hash = "sha256:bf12d4f014b2a33e642ed7905b5787656ede2fc24be21f7c086826ff0d32cec3", size = 26154, upload-time = "2026-09-26T09:51:48.071Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/66/66/a36d1367bd5ef70b6b97b2fb502f682106555e9e49e229f35c0a49d154d5/caio-0.12.10.tar.gz", hash = "sha256:5cecc44c04d5c6291d98b61bdba999f49a7e58dff2d782b4bb0642dc6f7ecf58", size = 84118, upload-time = "2026-10-22T21:35:06.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/bad818bd8657b4cce0b9541f2ce85ba60eb6d1de4c16d9d56521a71b89a1/caio-0.12.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1140246b2bdd2e46c33826464fee66cc2244345e38462d96b8055081acca213e", size = 41978, upload-time = "2026-10-22T21:34:14.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/a7f34badb62fcfe93e7bf8a3825a2cf1ac5065b5f7bab5f69d4c8e72997f/caio-0.12.10-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:fbb4da4f763e03838106de1050e0db0d5e477008bab1bb26da1f0928b50d185f", size = 160109, upload-time = "2026-10-22T21:34:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/4609b5e0286a4275e1cf80d4915946a36f12d20c26ef51d96acd2761310b/caio-0.12.10-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:369008753eb267b77ffec71791e3bf16e4c1db89e77ba176aa13ee83a19ec0b2", size = 156624, upload-time = "2026-10-22T21:34:17.237Z" }, + { url = "https://files.pythonhosted.org/packages/b9/24/f7ce50cd8c90083534db6b5a2438458da04e34317d13de2278e25dce76c0/caio-0.12.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:56fa63a876e94f1d52bcc690ef1179ba3d30d7b95fd14284b1ac2ae1e6effc15", size = 157630, upload-time = "2026-10-22T21:34:18.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ef/708e2fa732e1cc123249099ddcbe29037cf1d14b0adb9c3b0f7a7366f7d1/caio-0.12.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8a31733ad504fb4ce0fbe95625d1017a7aa54148afc13e674d36d0a8144fdf49", size = 156288, upload-time = "2026-10-22T21:34:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/45/ba/f545a34076181400dd0ad2b9883c4a5eeae85d142daf0f5ac2e3b718604f/caio-0.12.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f09366a7eaaa23f7dbf19c14802a3ba57b72e9efec463b89a8ff77bcbe3c8c09", size = 48895, upload-time = "2026-10-22T21:34:20.962Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/fc6c49b78d4168510f183ca502d9f0529b9b26db185e871d5331f3c57d40/caio-0.12.10-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:9f5add2fea913a7dfc0e196b76c8d16c059b04a6c1651ae0118e86be11ecd9ad", size = 162811, upload-time = "2026-10-22T21:34:22.023Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/29d14e56ab9aa18cbacce84cfa2d29c61befce876d24bb818412524c2c64/caio-0.12.10-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:2d7f65d34a27a0969db7fb810a6beeebea13327a37f7dae3a39da64fd2534b6e", size = 159023, upload-time = "2026-10-22T21:34:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/43/58/5f87b9a03218bdd8abe84f32e815712569ae765f4c635975c563eb0d2eb2/caio-0.12.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ca2fd82729aba943e190aefffd9dc96d49a1a401d466ea9743e6f54fa20f0e2f", size = 160341, upload-time = "2026-10-22T21:34:24.512Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/96bd3812166292f395a1e8fc217f75aac01eca476b95e13c0b1ed46480e3/caio-0.12.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2786535292cbd7cf1f13fb13d94f7173e71cd353bec4cb2e3de13d94b7615a79", size = 158710, upload-time = "2026-10-22T21:34:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ba/689a44c2f0d8ea68dfe2d721699fa81601dcc83f238102df22feb31697a0/caio-0.12.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c396c9a850681bf51dcc9943468260f6fa7861498f84b5f3b9413e9d3283cbec", size = 49062, upload-time = "2026-10-22T21:34:27.004Z" }, + { url = "https://files.pythonhosted.org/packages/db/20/21fd0dbf1b2b0211f3a451587b6d63d3ad99385487323f2b9062d6e42ff8/caio-0.12.10-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:93bb3a3f0bd684ac6b559c33a84817395c4d2c75d98dae2b7c40c57acce83737", size = 164716, upload-time = "2026-10-22T21:34:28.119Z" }, + { url = "https://files.pythonhosted.org/packages/95/00/8900d474b5e3285635865c9d5bdc71ad822f7932a7f90cd800fc837c1e67/caio-0.12.10-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:70d5032f3e0b2dbeceb12d418951415e936cff7f33132ac9f7759937ff1916f3", size = 162411, upload-time = "2026-10-22T21:34:29.402Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0704c708eb255f2995fc9830a969e883338c3de204e1eac726ec337a7e9c/caio-0.12.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:347ff5321a23675ed3b2396bbc6b7220ec5531303395c6b0135ed6cda04277cd", size = 162211, upload-time = "2026-10-22T21:34:30.617Z" }, + { url = "https://files.pythonhosted.org/packages/67/52/5b9ccb8205c3d3d870540f0ffea07977b68a9b018c91c43fae0bdb780954/caio-0.12.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd3cb22b0c5ca195b35c2b7d57892c23cc13d969f24c44fea876af2cfee02e7a", size = 161998, upload-time = "2026-10-22T21:34:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/84/3e/98ddc781ae6d76407b74f33136f838e444df41a1a43d80373da01da47bbb/caio-0.12.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6b8e0724415ae86889df59af2d44fd6a531851ebc62c9cfdb1cc8235c929524d", size = 49054, upload-time = "2026-10-22T21:34:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/9851c6b24674ef72054c7b05569bd444f1358955c19ba5b5f7e15fa64be6/caio-0.12.10-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:4e72602d4c348d105c9e46dd300b85df3fd3e857e2068370847f5e29879181b5", size = 164861, upload-time = "2026-10-22T21:34:34.472Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/1f6eac05eb23284c834c08c17cc5f39fafe3754f1d2df94bcf5a2cb93179/caio-0.12.10-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:1d8b6e9275d8f9608b8ecb4b97189e7f1d0b8ed948fb552eb06518d6342a1e53", size = 162592, upload-time = "2026-10-22T21:34:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/4bb0538aa4c953c4681c7b309a9cb452eb91640e6b62f250b464f91dfad4/caio-0.12.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cff7a239334b36c4dc82a0ab69aa3244188e5c9e2c30a242d81e4502520f2b1b", size = 162324, upload-time = "2026-10-22T21:34:36.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9a/290190821dbc29fccb0ad96e51a7f573394df621da547cb13701de685805/caio-0.12.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a9645aeed1cdeeff34ca78442e6b274c0f801876a6bf0991724d92b7d639655", size = 162171, upload-time = "2026-10-22T21:34:38.173Z" }, + { url = "https://files.pythonhosted.org/packages/39/9d/2a2177a43901f69ef120fb0dfe4a7dc6938e57a086da3ecdeacc12a05635/caio-0.12.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:edda39ac24b4cec74486a06afe7499fc9ddb2c3d5cb98494b2c9fdf95ed7aecd", size = 42798, upload-time = "2026-10-22T21:34:39.444Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0a/5e404a09363c71dfb5f7c20234ee31dc9ad75c7072dc868b6cc5be6f9af3/caio-0.12.10-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:3b9c6e6355267f2eccd23e1a17ec2f78dce02ab00b10276999ff7a4615512952", size = 42033, upload-time = "2026-10-22T21:34:40.448Z" }, + { url = "https://files.pythonhosted.org/packages/06/94/41549be5a09a0441f1c1313d5eeb8b60b08034c89cd2c0b5cfeb6a9b9111/caio-0.12.10-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:a2b897245d5e6593a39ea9a52c7b372efc41e5a746c46a2985bdf94622f273fe", size = 165369, upload-time = "2026-10-22T21:34:41.513Z" }, + { url = "https://files.pythonhosted.org/packages/28/47/f272ce0eed9700749934823086434a94d9bea51555724188d8ee660a7fd4/caio-0.12.10-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:40e84b8679f44bee5c2b7cc7093719e15616b6752560809896dc15a8667cd69f", size = 162620, upload-time = "2026-10-22T21:34:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/65/ae/644e7e72a81c4d8ce31dac9865b2a6b38fdc4c56a1418cc2d4d55591be20/caio-0.12.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccd4efddafd48018d30cd560f7c393d177fe01365ee0dcf0bc88528f220732ed", size = 162986, upload-time = "2026-10-22T21:34:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/63/2a/557a90ae71f8411a2cfa0238ad70bddb60178133b7eb41e3fe8e8c854235/caio-0.12.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2138670dd6cab64bd29cfcbd6a7259c0c21646c4dc86a4ec12de907210a2aba9", size = 162258, upload-time = "2026-10-22T21:34:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/78/1a/1ffea8df50383d5bc468e94d2b45d1c458f965b80bb12ad28891d0a55266/caio-0.12.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79a4e0000dd92b1e4e7842fca99fdaa5fd0259dd33a9892741e7126e74656c49", size = 42801, upload-time = "2026-10-22T21:34:46.485Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/43da115afd82281312166711ceff5b7ca08d53f79a8955d2e22ac9968eca/caio-0.12.10-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:6fff1f609e54a2e007b6c5c0933f4782ecbf8b6f9144e0930e2dcd2e7440c637", size = 186884, upload-time = "2026-10-22T21:34:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5d/708aed113a2ae829a265b5ce030bab52ff0e41339cc6047f29bb0b15dfdf/caio-0.12.10-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:925b938bcdf09d1c3df2b6ddd534004b616451ef1810e47f6a63257365376b25", size = 182799, upload-time = "2026-10-22T21:34:48.86Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/8de2c1c89548902a389406560e67930333984194217d5bcef6129ff7f18a/caio-0.12.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1ebcac2a14e045aa0a4f63824264a4a845c6b1ac9074d5d673c52ef564a1ada6", size = 185097, upload-time = "2026-10-22T21:34:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/43/98997e24fa52f1e6a2a808e446693702773830a863a036a5e727024e7aa0/caio-0.12.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:39e91aab7d0a5f0cac5930562d751e9938ceef1c7ae18477a5689a4e437eada1", size = 182593, upload-time = "2026-10-22T21:34:51.256Z" }, + { url = "https://files.pythonhosted.org/packages/c3/17/78046176bc3572430d03bab1b9e82966bc5b9a28d27c41e6764a20144044/caio-0.12.10-cp315-cp315-macosx_26_0_arm64.whl", hash = "sha256:c0b52bd6fd006d2d07713025f0e1cad61ebd12342f86bfa47e2eff239fbdd01f", size = 42039, upload-time = "2026-10-22T21:34:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/14a90cfe02f14813674a1cc91ff2fad9b0d1bb98260be09b987f579baa28/caio-0.12.10-cp315-cp315-manylinux_2_34_aarch64.whl", hash = "sha256:b7a42ef7379e36e99cc6990aa62465919a3be3fb1f049a40e5aef29fbbd859e1", size = 166510, upload-time = "2026-10-22T21:34:53.703Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b1/9fbeafd6aae00cc0dae96d190cc4fe6e23d20ecd4f26e970878611761fdb/caio-0.12.10-cp315-cp315-manylinux_2_34_x86_64.whl", hash = "sha256:89fe0e3c849a07aba1f76012a50eaf4fb2eaed0398c01bd43912a1bd0f4ab5eb", size = 163501, upload-time = "2026-10-22T21:34:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/34/81/2342f17ab96a7b5d3666cc7dccea59728c4d421e7c954e2937cd817c3be5/caio-0.12.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b04f6722884e5fb19690743772875b34306e5ba68d9ecabf7454193bb5caab", size = 164139, upload-time = "2026-10-22T21:34:56.201Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e15cfdc902a13d885fa16481c7d01ad04de7b50e0195b9dd5e219ce07614/caio-0.12.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a04ac9769fd8eddc1c0ca6308f0f6e668a86cf8d577770c11d85d0aeb584fcf4", size = 163137, upload-time = "2026-10-22T21:34:57.402Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/7fda5be8557afa8833ea65ebb29feaeeb96cb2b3373fe3220fe1e6899260/caio-0.12.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c8fc80a37f08bba4c2f108277b6eabba564f74963a1a6cab01e50f9ae4434681", size = 42800, upload-time = "2026-10-22T21:34:58.494Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/e2692eeb6841614f60ec099eac3c9b10a00dac74adf7321f442a2291ef8d/caio-0.12.10-cp315-cp315t-manylinux_2_34_aarch64.whl", hash = "sha256:3e3799d09b08401e83c30c671f6fa1e1227f6346a3b1336f24cb94c5a8a82b64", size = 187471, upload-time = "2026-10-22T21:34:59.585Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a8/2c3a0cd781732057ab6a8023ef0fe14017b29ef75259295df8f585e3dfba/caio-0.12.10-cp315-cp315t-manylinux_2_34_x86_64.whl", hash = "sha256:ea2e0ea832d36c1e50ab730ba304153f34eb7dc2300b1285fc7cdfae1f1f4075", size = 183526, upload-time = "2026-10-22T21:35:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/9f32db16441875afaa2a8a27d9734c2c8421f259163d7d814528160fa6c8/caio-0.12.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1071f8cb5d23c8aeccffe36c50a17d32257cddbf913b7e9b6fb524ba42d7a8", size = 185740, upload-time = "2026-10-22T21:35:02.059Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7d/17836b477a1e57a648b90f16a27ebd367d686a02e5b2a4e72b53ae5ad7fe/caio-0.12.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a54ddadb7db4eab50bfc8b34f36fcf6763e9007f86b92f7cd03f07b542e891e", size = 183396, upload-time = "2026-10-22T21:35:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/1c44005ec4d4c3f7c51b707308efe7836ee16c466f4ff2e2a6f2089eceee/caio-0.12.10-py3-none-any.whl", hash = "sha256:b34a5483b987d207348ebbf86921bc42f08abcde833b91e0caa8760c7daa2f03", size = 26166, upload-time = "2026-10-22T21:35:04.842Z" }, ] [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -228,7 +228,8 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -580,8 +581,7 @@ name = "cyclopts" version = "4.25.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] dependencies = [ { name = "attrs" }, @@ -598,7 +598,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "5.2.0" +version = "5.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -612,9 +612,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/af/8254627de20f769193afde3b4fdffd0f3b5c5c0e8a756b6088b6e308ec2c/cyclopts-5.3.3.tar.gz", hash = "sha256:86182ba223839686d52178c2b6151b28de8fe9d02cb64d290fbc86341a4a5b76", size = 255373, upload-time = "2026-11-02T16:01:51.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/dd/3f73d04c95f6acc6a54700011637e49362e337139b518be895edf4786f87/cyclopts-5.2.0-py3-none-any.whl", hash = "sha256:5da2a5d65164e03008621fc4795946b88a722b881398b5dfa58afa6218342cff", size = 289196, upload-time = "2026-10-06T15:02:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/a5b9e6fe5420461af7166e0d9f3b120e42383b14bc84edec9d696439b51d/cyclopts-5.3.3-py3-none-any.whl", hash = "sha256:b16ae3619f9447f6b7e42834586e2def254b940ce0b39f82eaf3cfee788a924f", size = 298068, upload-time = "2026-11-02T16:01:49.463Z" }, ] [[package]] @@ -622,7 +622,8 @@ name = "dataiku-api-client" version = "14.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "python-dateutil", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/e1/336e135820f414301839e57b7d337925f955fd5bc02ba1eb39c4f46d21e0/dataiku_api_client-14.7.2.tar.gz", hash = "sha256:476efce84ce6fd69dd226c85690ea83fd7cbba77276c3d2c0afa00ce7dba5031", size = 416594, upload-time = "2026-07-22T07:54:50.998Z" } @@ -631,11 +632,29 @@ sdist = { url = "https://files.pythonhosted.org/packages/96/e1/336e135820f414301 name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, 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", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "dnspython" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/49/900383b471c645a3c397f47b3211d1fcecb3a540c98f683494cdf86b33de/dnspython-2.9.2.tar.gz", hash = "sha256:8d4d39a513e649d634004ce4d39ade2352efcea472a0247535a5c2d18a9d7ba9", size = 455484, upload-time = "2026-11-03T23:56:53.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0c/5346358b3a20275d256978c35203ddc2c8617f78c1b23795b79ba22ced6d/dnspython-2.9.2-py3-none-any.whl", hash = "sha256:af15f7d385a6ae2fb35a796fde2138004c8c42c7389090e2addc83e3b3340fd8", size = 360455, upload-time = "2026-11-03T23:56:52.088Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -650,7 +669,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython" }, + { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "dnspython", version = "2.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } @@ -687,9 +707,11 @@ name = "fastmcp-slim" version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, + { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "platformdirs", version = "4.14.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic", extra = ["email"] }, - { name = "pydantic-settings" }, + { name = "pydantic-settings", version = "2.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pydantic-settings", version = "2.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "python-dotenv" }, { name = "rich" }, { name = "typing-extensions" }, @@ -707,17 +729,19 @@ client = [ { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "starlette" }, + { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] server = [ { name = "authlib" }, { name = "cyclopts", version = "4.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cyclopts", version = "5.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cyclopts", version = "5.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "exceptiongroup" }, { name = "griffelib", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "griffelib", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "griffelib", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "httpx" }, - { name = "joserfc" }, + { name = "joserfc", version = "1.7.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "joserfc", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, @@ -726,14 +750,16 @@ server = [ { name = "packaging" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pyperclip" }, - { name = "python-multipart" }, + { name = "python-multipart", version = "0.0.32", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-multipart", version = "0.0.33", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyyaml" }, - { name = "starlette" }, + { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "uncalled-for" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "websockets", version = "17.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "websockets", version = "17.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [[package]] @@ -741,8 +767,7 @@ name = "griffelib" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } wheels = [ @@ -751,7 +776,7 @@ wheels = [ [[package]] name = "griffelib" -version = "2.3.2" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -759,9 +784,9 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729, upload-time = "2026-10-06T09:54:37.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/e2/0f5d8ed08766bc6dcd3eebf5a6623d83cbca33a6e4c90aef57156ce745ad/griffelib-2.4.1.tar.gz", hash = "sha256:40c028dd2e623f464423f0e11e3c553158f1cf4f1368239a370534d73e1407ff", size = 245153, upload-time = "2026-10-22T11:20:03.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420, upload-time = "2026-10-06T09:54:32.038Z" }, + { url = "https://files.pythonhosted.org/packages/de/02/1156d5f5307f573b901b14373848b84e69ca6cab09169b8cc97d16a181f9/griffelib-2.4.1-py3-none-any.whl", hash = "sha256:76bc562f936ad1ce198d2cf6d0731bd5c92a7d934b69ef828ef22ee0ffdb62ce", size = 172983, upload-time = "2026-10-22T11:19:59.843Z" }, ] [[package]] @@ -823,6 +848,9 @@ wheels = [ name = "importlib-metadata" version = "9.0.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "zipp" }, ] @@ -831,12 +859,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, ] +[[package]] +name = "importlib-metadata" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f6/4eda8458eb00ec03e715c376c6b23b3def69687924c68d36d1c4003d3bc4/importlib_metadata-10.1.0.tar.gz", hash = "sha256:82b04047fa09792c1a8cfeddfe08bec79e4b13c29c8469b0d3949860f61017e8", size = 67761, upload-time = "2026-10-24T00:01:44.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b6/495438ad95e0102aff8f6786b5474202f38cd5d4700d03a72b77977dec10/importlib_metadata-10.1.0-py3-none-any.whl", hash = "sha256:cf45ce66a929c64e994185a809c04840d0501db17e663810d993d0f4829265c7", size = 32221, upload-time = "2026-10-24T00:01:43.507Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ @@ -860,7 +905,8 @@ name = "jaraco-functools" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } wheels = [ @@ -878,14 +924,35 @@ wheels = [ [[package]] name = "joserfc" -version = "1.7.5" +version = "1.7.7" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/15/48ff246732232d35ee234228def22b52455fcc55577f06146602399a29b0/joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510", size = 237446, upload-time = "2026-10-19T00:30:29.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, + { url = "https://files.pythonhosted.org/packages/df/9f/934db284aa22f37af59708e382903860d4b81329c137b1b0ed2745daeb65/joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c", size = 71758, upload-time = "2026-10-19T00:30:28.498Z" }, +] + +[[package]] +name = "joserfc" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/31/5c3f27e82c92a7bbe1f028033ff19e0251540395c9be244c01a50de42bdb/joserfc-1.8.0.tar.gz", hash = "sha256:cbf03cb2c6db8cc5289f1ef8718842e53dba244b4c0de5fefa0c0eaee4bde8db", size = 241023, upload-time = "2026-11-03T10:45:57.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/7e/7ffaae13415e8f51c8e8d5d1845f2de7a27ebf2717e97a38d7e7446d6030/joserfc-1.8.0-py3-none-any.whl", hash = "sha256:176b836ac7ed9a46a88629e24076314b475f33a75d0bad7f26146e572eed7c79", size = 73147, upload-time = "2026-11-03T10:45:56.531Z" }, ] [[package]] @@ -945,7 +1012,8 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "importlib-metadata", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "importlib-metadata", version = "10.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, @@ -980,12 +1048,16 @@ dependencies = [ { name = "httpx-sse" }, { name = "jsonschema" }, { name = "pydantic" }, - { name = "pydantic-settings" }, + { name = "pydantic-settings", version = "2.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pydantic-settings", version = "2.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, + { name = "python-multipart", version = "0.0.32", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-multipart", version = "0.0.33", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, + { name = "sse-starlette", version = "3.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sse-starlette", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, @@ -1008,11 +1080,29 @@ wheels = [ name = "more-itertools" version = "11.1.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "more-itertools" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63", size = 154133, upload-time = "2026-10-14T18:50:30.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", size = 74405, upload-time = "2026-10-14T18:50:29.116Z" }, +] + [[package]] name = "openapi-pydantic" version = "0.6.0" @@ -1027,14 +1117,14 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.45.1" +version = "1.45.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" }, ] [[package]] @@ -1059,11 +1149,29 @@ wheels = [ name = "platformdirs" version = "4.12.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, ] +[[package]] +name = "platformdirs" +version = "4.14.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9a/8475be631fb33ca1466915e70fd594c42a4e84b512db650eaa14f90fe75d/platformdirs-4.14.5.tar.gz", hash = "sha256:32e9921db7385aa8169bee4fe9501923e5715ba9fb7ea3b9c69b0d8e0639f7a1", size = 71464, upload-time = "2026-11-04T16:54:50.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ae/ecbbbfd0e4343a6a521d49b3c8656fb4643dee2a4f6eaf941cf3280391a8/platformdirs-4.14.5-py3-none-any.whl", hash = "sha256:8e778c6fa421516f4d3369449f8df3b0e1b72c0e0005fe88e3ec772d0a14e750", size = 35402, upload-time = "2026-11-04T16:54:48.875Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.6" @@ -1092,11 +1200,29 @@ memory = [ [[package]] name = "pycparser" -version = "3.1" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" }, + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, ] [[package]] @@ -1239,6 +1365,9 @@ wheels = [ name = "pydantic-settings" version = "2.15.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -1249,6 +1378,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.16.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/31/21d21e0e38a8f5f7a7f0cde22820117b6d17d6bc36f348129527f915d7f4/pydantic_settings-2.16.2.tar.gz", hash = "sha256:3dc08ffabed2bbd5dc2dd55ead7b78b4e4c5cd776aecd04310db5f1224caaf46", size = 309225, upload-time = "2026-10-28T11:14:28.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/88/4131c51ff87b7cd7f82a1194d0c0a2b7ca68c72d9da04c3b7e69ab91a79f/pydantic_settings-2.16.2-py3-none-any.whl", hash = "sha256:8d523e5c129b097686bde019bd6109bff53f4c8c3f63f1ddf9933ffa42e5ab4f", size = 81994, upload-time = "2026-10-28T11:14:26.716Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1286,14 +1436,36 @@ wheels = [ [[package]] name = "python-dateutil" -version = "2.9.0.post0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "six" }, + { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, +] + +[[package]] +name = "python-dateutil" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/39/b3927f3616f6223832f9b760e4208e8a7c7e1dea2b4d9ad7d32a5d214c3a/python_dateutil-4.0.0.tar.gz", hash = "sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06", size = 250419, upload-time = "2026-11-01T07:02:42.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/a9/52b89563d154433d79b51ed1eb4842bf7334cb2696891a7fe135618853d7/python_dateutil-4.0.0-py3-none-any.whl", hash = "sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c", size = 92853, upload-time = "2026-11-01T07:02:40.797Z" }, ] [[package]] @@ -1309,11 +1481,29 @@ wheels = [ name = "python-multipart" version = "0.0.32" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.33" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/b7/15b905f6142a9a8a68f66900788da1a32e4d4254452783b37ad1eccb0506/python_multipart-0.0.33.tar.gz", hash = "sha256:b8112424a6abd162ec08d390565dd113ad699f2fe6fef3c987b8bbcbfe9e390d", size = 50499, upload-time = "2026-10-31T14:23:47.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1b/6b1b4164ec734cac911460ab599bfbe46afedd42c5c9627fe4235942c314/python_multipart-0.0.33-py3-none-any.whl", hash = "sha256:8f16c07b36add9da6b2ee87d3efcaf0a45a6de9c3d0a5dd94e9ccf7675a7bc45", size = 32253, upload-time = "2026-10-31T14:23:46.33Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -1473,8 +1663,7 @@ name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ @@ -1759,30 +1948,74 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "anyio" }, + { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/2e/16b9cce59c2eebd2f944b41b01df119f8ee65535ce839c462f1b2c9ae2e0/sse_starlette-3.5.1.tar.gz", hash = "sha256:dbb1461cd53c1e284537c3ddcf92f54f24178519a3f1e335e49e5283993db0f7", size = 38356, upload-time = "2026-10-24T08:45:12.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/98/658d1681bc6dc0b0f7f695aee4337de79decd0ccaec6d000a76b5cbdca6d/sse_starlette-3.5.1-py3-none-any.whl", hash = "sha256:a6cdcfec47b952911b1d99a7b8056429b5984039b875bf6575174220364b551f", size = 17538, upload-time = "2026-10-24T08:45:10.981Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] dependencies = [ { name = "anyio" }, - { name = "starlette" }, + { name = "starlette", version = "1.10.0", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/7f/3b8ec4f27833f7b8c270a3ef960b6d435e1065439847bc0fcd1e58ae898b/sse_starlette-3.6.0.tar.gz", hash = "sha256:623151d6c20dc0eebf1b3379c27faae91fff883bac7ab6665f16f46440a88b3a", size = 41174, upload-time = "2026-10-25T20:31:59.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, + { url = "https://files.pythonhosted.org/packages/cf/49/35053078cdfcf639237e8bee6dc6f34f1b3a3cc8d01800a1e213e1660ff6/sse_starlette-3.6.0-py3-none-any.whl", hash = "sha256:e4dfa4d237d2712cb2b4d4918eda9b457cd6b44bbecdc838af75de836565bafd", size = 17985, upload-time = "2026-10-25T20:31:58.134Z" }, ] [[package]] name = "starlette" version = "1.7.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, ] +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + [[package]] name = "tomli" version = "2.5.0" @@ -1855,6 +2088,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1866,23 +2108,32 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.4" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/50/0195198eb1d75bab716bb0a813abea4cc394bfa17a92a189f021a34859af/typing_inspection-0.4.5.tar.gz", hash = "sha256:7f16c411e7930dc79596d42ab44672a3fe5f60eb34b781a1647e7872363120c7", size = 81194, upload-time = "2026-11-04T14:13:23.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/0c/ea2544850a7c135e15033a5c7a8e7211136184b7ae63e2239d557c2b3019/typing_inspection-0.4.5-py3-none-any.whl", hash = "sha256:bc210535442b39e1bf2805e0c7a17dfd6a4ace760c84c7f7d16245d23a6b1c39", size = 14874, upload-time = "2026-11-04T14:13:22.616Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" }, ] [[package]] name = "uncalled-for" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3f/3367539c8c619908b8e26db13634d2265ab2ec01e86573699c529dc1982c/uncalled_for-0.4.2.tar.gz", hash = "sha256:d274d6ab5eb0751e9b71c73647dc25a07d56b293dea479e689056aa919363133", size = 84827, upload-time = "2026-11-03T18:53:12.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/e709d69accc4f8b36cd2c64f6bee0fef10d7d78ffe9895d00c87bc042891/uncalled_for-0.4.2-py3-none-any.whl", hash = "sha256:e35b2970b1f90ae393c510c426357eac0e8da083b62b06dbefde704d69bb2645", size = 14900, upload-time = "2026-11-03T18:53:11.639Z" }, ] [[package]] @@ -1956,8 +2207,7 @@ name = "websockets" version = "16.1.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } wheels = [ @@ -2073,7 +2323,7 @@ wheels = [ [[package]] name = "websockets" -version = "17.2" +version = "17.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -2081,162 +2331,168 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/f7/8a90cc2abbe4709dff4450824beb07cbf7256566ee043c2ba3faa1d5fb2a/websockets-17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:569ed5db651e420b13279f9333443bb5b84a436cc66b599cbc535697ae4434a0", size = 217725, upload-time = "2026-10-03T14:52:50.797Z" }, - { url = "https://files.pythonhosted.org/packages/7f/85/e418ba2e7e412a5b35c42caf6d4fcc8ecee1a66edc4f2a5f780da775aa77/websockets-17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3892d76754b5f36fb40619f3ef09c68e5c3091f1ab8840964518ae5a41f30952", size = 215415, upload-time = "2026-10-03T14:52:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/e4d7eb2e2e4ffed0b0dfbd2d1aa3c8101f42d34ac9f58b47b822c565d1d4/websockets-17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5436ffea003adb50e283ca0684a3fcaa1396104f841736c3322ee6582bd09e98", size = 215690, upload-time = "2026-10-03T14:52:54.173Z" }, - { url = "https://files.pythonhosted.org/packages/4b/dd/e8718fa6114c4cd15b05133b548af985638e80774253c1faee8d49874c38/websockets-17.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9df9d048def11365d170b375b6ffc8b23a7f188c3560acd4418ba088ca2e2705", size = 224756, upload-time = "2026-10-03T14:52:56.132Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d5161c46f3eee2ae67cdec489532b51695a1c27ccfadd858dcd419ea26ac/websockets-17.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:376a693697ddb695ea282ead76060f4847f90e564b12b4389f2c7589e6fadb9e", size = 225026, upload-time = "2026-10-03T14:52:57.671Z" }, - { url = "https://files.pythonhosted.org/packages/d5/9a/3f83bace9636af07d7bb00cbae0bcb5bd1697892babac79664f3a2b3a011/websockets-17.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd63d0c7ed0d3d719c91b5a3861f0f0b3cec9bf223033ddf69d17aaac74bb6d", size = 226260, upload-time = "2026-10-03T14:52:59.114Z" }, - { url = "https://files.pythonhosted.org/packages/03/50/5347cb13f97430526b9c31e9b30fa639bb1d0f9d53074da8622b327cfb6f/websockets-17.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:48997ed4431d8006988788ef4b62e1fd3f053c7463b4fa793aa6c4f9e96a3bb7", size = 229573, upload-time = "2026-10-03T14:53:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/14/2b/7511082e3fe0cc3233ecb0c3b019ef12c1cd9df60ac1a7858f6093f490b5/websockets-17.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e312e07557a5ad348f4e83d3419773527f6e790c7f97928b1911d767b6ea1c7", size = 226819, upload-time = "2026-10-03T14:53:02.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/4f/86c1a9db323d4fdbf56cc089942f18328a48c3efbbad0d625a66a2195842/websockets-17.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:902ce8cafca2dc14cef9558a6fc3b45dbf7f121d1404bf2ad18a1c894555e48c", size = 225595, upload-time = "2026-10-03T14:53:03.768Z" }, - { url = "https://files.pythonhosted.org/packages/81/92/4f54f6031d97e284e01a0728cef38b095478dcaab81837aac8cb0e26ea6a/websockets-17.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e53d950e16d4bb672a5ff41fe3131e65a4e5d688d694e1c7074c8c9990bb3ceb", size = 222875, upload-time = "2026-10-03T14:53:05.7Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/c6d59b8b45c730a56ee5acf6c0ce9896356cba25ef3f9a4c9d1796f2e44f/websockets-17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:946ac2164d646e733004946ae39536b5af473853183d81da5962e29d36e3ad35", size = 225745, upload-time = "2026-10-03T14:53:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7c/5d9b91b43aa339b96551630940a847270c10a9d70243be4c81fe5dc6fb34/websockets-17.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:660aa158127035e741d4b1835dbe79ae18a1fbb21ecd236655f31d60110e68d5", size = 224342, upload-time = "2026-10-03T14:53:08.893Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e1/c90c24b0dfb12b8b6f0d5e13fc7cf9f121a2e072f7f54bb888da826b2012/websockets-17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4733fc2d99fe888261417b7e29995403a72d9ffa78629902882325ea141177f2", size = 225109, upload-time = "2026-10-03T14:53:10.495Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5b/f38ca1299c10ea1cfc7f1d129c65a15e4f4b281d1f3dc25891d5fb9bf9db/websockets-17.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2ec7e51157a3fa0e9cfdb1a8969bab38d1c22ad1ace7c6cea006383b43a1ad4", size = 226151, upload-time = "2026-10-03T14:53:11.976Z" }, - { url = "https://files.pythonhosted.org/packages/f9/21/ff6089c6921c7ae0e1801a4948aa1a3831deb1596e8f0d1cd3a0c0e44109/websockets-17.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ada04d0262ab06527054a2a497f384d102698ff39b3865dc566a7d24b6f4058c", size = 223732, upload-time = "2026-10-03T14:53:13.864Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c4/01ca4212f665e351123c84e7f7156badf5da958ef8aad8781b538682c699/websockets-17.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c393a202df08e96ed619310f0cd78be700e532a57d9a6ceee5f80b4e35bef14", size = 224764, upload-time = "2026-10-03T14:53:15.411Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/bc17b39d1e62b771d8a417b714439252d7abfca21185242cc293d75b20d5/websockets-17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af4c565b923bb5975401b8e4cedc2e17b2fdbf33b905737ee12384e6a6fd9507", size = 225002, upload-time = "2026-10-03T14:53:16.93Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f6/ccab831ab6a841a35134937a1794c0f3f09ccc604625505be061dec5b3e4/websockets-17.2-cp311-cp311-win32.whl", hash = "sha256:c81d6cdbacccda7e0eef3b076a457fd14c3835cdbc5993d2881580c2fb1f5f26", size = 218226, upload-time = "2026-10-03T14:53:18.376Z" }, - { url = "https://files.pythonhosted.org/packages/0a/18/4fcc23f2159393ad7a668574ee97ee5a135003bfcbdd56b30581110c0fe8/websockets-17.2-cp311-cp311-win_amd64.whl", hash = "sha256:55c5b9eab079540bfb639b40b07b7b467e5c5a7ecf97a65cc8665781381c9856", size = 218523, upload-time = "2026-10-03T14:53:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/5a3f4f75dadb7fbf980ea4b59d02528f87fb2d3c0ac120c2ff50d1dc1b34/websockets-17.2-cp311-cp311-win_arm64.whl", hash = "sha256:55f9a808a0e072473337c240c939849818276e288e2374b832255b5b791b0851", size = 218454, upload-time = "2026-10-03T14:53:21.417Z" }, - { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, - { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, - { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, - { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, - { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, - { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, - { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, - { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, - { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, - { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, - { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, - { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, - { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, - { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, - { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, - { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, - { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, - { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, - { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, - { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, - { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, - { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, - { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, - { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, - { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, - { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, - { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, - { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, - { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, - { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, - { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, - { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, - { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, - { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, - { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, - { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" }, - { url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" }, - { url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" }, - { url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" }, - { url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" }, - { url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" }, - { url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" }, - { url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" }, - { url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" }, - { url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" }, - { url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" }, - { url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" }, - { url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" }, - { url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" }, - { url = "https://files.pythonhosted.org/packages/7f/e2/09ad9cec0fc7e39f983b52f9e49c44f89b7cf7a61d4761fa7fc398f003f9/websockets-17.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2de1ccf298f5c9e0f27113836d742edb95f015eee3148f004ac386f7ba9a05b1", size = 215348, upload-time = "2026-10-03T14:56:41.037Z" }, - { url = "https://files.pythonhosted.org/packages/80/fe/c307b5d8cdf1852d00606a0403502f0ca5cd8a4736550bab70abce09f7e9/websockets-17.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:761cde41439f0be761aa460e1451a31e2e14baf4a46db6fe4913e5a06a90df66", size = 215621, upload-time = "2026-10-03T14:56:43.097Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/af8412f154cd0568afc043ab478cc8c1ebdf9337b25c85cb9a049d18cfcb/websockets-17.2-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:15a7101b660a9f15fac34108c92cefc9848f6753a50acef8869e3cd94148fdb7", size = 216569, upload-time = "2026-10-03T14:56:44.979Z" }, - { url = "https://files.pythonhosted.org/packages/fc/76/92ae57b985378036bb8133ea39d1e5cc4d97accad9cae38169426bdcef75/websockets-17.2-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:214da56dba368f61b3d745c77630b2d03c61c02da7b42fe80ef6efba079d3077", size = 216462, upload-time = "2026-10-03T14:56:46.771Z" }, - { url = "https://files.pythonhosted.org/packages/e5/35/e3b276473f7f38984990eb29cf525ffaed131f6136bedb929b5c2ce7151e/websockets-17.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80cbc645af23ac5c12096545c161626960114a1bc10f864760558d3b3e82ba18", size = 217355, upload-time = "2026-10-03T14:56:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a1/459ab96c5cda8a2164f594be6dc9f868de7971e6abafa696ea07534139a6/websockets-17.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:063508ce9e0db745f30ab52fc652f4e59efc79c2b74934b3837d5cdb974da620", size = 218612, upload-time = "2026-10-03T14:56:50.287Z" }, - { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/79/590c5f0cc7d1661195aeefccb0e342b79f4c77d368d526d028821a390332/websockets-17.4.tar.gz", hash = "sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb", size = 191391, upload-time = "2026-10-30T15:10:35.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d8/2d4ec550208432538d152a41f0c36dc6728cfae0050c6b1442605456d3bd/websockets-17.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06ea46717553cdd10f2682126cf7a66e91c8ed17b6969f4a95e94b4e01d06188", size = 218746, upload-time = "2026-10-30T15:06:39.361Z" }, + { url = "https://files.pythonhosted.org/packages/56/99/3090fc0e2e343cc7ee60d532b67a2d413830c8fbb0be9ec3bcfbe3371f2e/websockets-17.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75b32cf33ea40f945f226f97bc1d43151e1a5adff21f420b5e63c468b1a28114", size = 216437, upload-time = "2026-10-30T15:06:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/3d4fb98e5d8aecc7bdbc59c4edf6380fdeddf935445e7f4a205466682991/websockets-17.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8451f31ae24620cc9372acd2728d87922f2bb0cae3fa57aed68b152844cdf08", size = 216709, upload-time = "2026-10-30T15:06:42.995Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4d/5c7d4f53cc0d7c6f268bbd93865b185e05f65f37f84712a41ab311118f8f/websockets-17.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3b76837cd051d8875913f3934b27205f7a592f78b8a4f4a1f3abc36a2420c5bc", size = 225773, upload-time = "2026-10-30T15:06:44.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/70/80f870701f8610e88649d3cd173a0e7ce66433913da3683035a4370063ad/websockets-17.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cebad8f4796248b91fc2168515e507e5cb6b663a32befd09f3b1e7920acdcb1a", size = 226041, upload-time = "2026-10-30T15:06:46.224Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/f2e84b5a933c86d52184814d38b0f9d51fc90fd3205cb7af0d9417ebbff2/websockets-17.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:954d43574f72be244b65f0fa61e2f814214b024f2ec849f487aa2f553f7c6e71", size = 227279, upload-time = "2026-10-30T15:06:47.619Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/4710475f46afbfe5390b4139556aa4fa0692d3d31878a25a1b26e8a6d78a/websockets-17.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b529ad6c81ad21db2f4e3d6e33db78ce3b9f272066b438b5d673361f9282898d", size = 230592, upload-time = "2026-10-30T15:06:49.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/10/cca6dc2316dd8c3b1a62fbf7f8986926ad8c4b2ae3d3be7888059a96b142/websockets-17.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:799c5a23126c9589cb10b6c6b54f9087246940dad20cd74bf3f06fc9ae3bbab5", size = 227837, upload-time = "2026-10-30T15:06:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/68/21/ac1c3d7781d756bd40fbe501890b54a0205b1f0528a559fdcabcede0bd54/websockets-17.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f01a680d4b66fd15cc7513fae55c673cc8cc340d41340305b7f9a782abdbefed", size = 226613, upload-time = "2026-10-30T15:06:52.556Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a0/bd122578f2765fb3714eeef80bd66074e355ff80e0a6de7fefa10de5f36a/websockets-17.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e51546a3bedadd80dfa5c2efaed840ac51b8632d8db7f9f502175f5349382a9", size = 223893, upload-time = "2026-10-30T15:06:54.106Z" }, + { url = "https://files.pythonhosted.org/packages/51/50/a5214db6b778995b7fe1bfaab6228ba967cb6fd523d4b7af83154aa0ac66/websockets-17.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6686d29eaafddeeab9e48af92103dc0d26161035faa4be083b8b488f1ab47219", size = 226768, upload-time = "2026-10-30T15:06:55.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/5d62d4889ff8a400cf32c8ad35e58b18e92b0e936db0bae785307cd1b105/websockets-17.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7b23db4b469d9e051ac74d96db9bf61e27c9054de64f8263fefa32bf72867ba1", size = 225358, upload-time = "2026-10-30T15:06:57.706Z" }, + { url = "https://files.pythonhosted.org/packages/b4/94/c344744fad06ae721a6ec69f728a3dd4bd1b94beb7bb64400a8cd0efc644/websockets-17.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0132e3cbd3f5cfc1ec77588961b302d72a02e2603b82c2c23fd5fb59dc4fa206", size = 226125, upload-time = "2026-10-30T15:06:59.125Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/90d66792bf1ce315fabe18f838eb2090ebad3425139047ed4c4ad354a90c/websockets-17.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98f16db5e79f2605194c0f899d0532005bb12269e698bf96f62a5635bcf33010", size = 227169, upload-time = "2026-10-30T15:07:00.687Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/c069129b20504c35bd22abb276a6562e92dccb69c974ca93021a23fb00ce/websockets-17.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ee48728a07566c32e097d267f4d4d01b85123c9ae748a42da02185b908cbedc4", size = 224750, upload-time = "2026-10-30T15:07:02.077Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3a/0fcb37d6da690aa2512faadb0e9cb5e1043fff659fd415dc8b518883789e/websockets-17.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2103982762460a764a7ef0021443bb471f46d45ecc6858276e4fe699ebac6b94", size = 225782, upload-time = "2026-10-30T15:07:03.783Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/422745448862b18b8d373347bb73debcd440330f21dd899feac5f920d531/websockets-17.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:648adde345cfd13335342e23cfe94871c7f7f55a43fb254f3b95acb071072968", size = 226021, upload-time = "2026-10-30T15:07:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/74/3ea38c6758b497a5d1b7bd953542e9e6446b84aa075a061e9a47e24ceb75/websockets-17.4-cp311-cp311-win32.whl", hash = "sha256:679eef0123861ca381dd8fd9bdc1920e87636ac55eae6cff875c53dcb05fbcf8", size = 219255, upload-time = "2026-10-30T15:07:06.871Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/00ca8b75a634c9fcf70579f9a242b96a7f7a3abeb21a02e2380fff491d2c/websockets-17.4-cp311-cp311-win_amd64.whl", hash = "sha256:e4beaef2f4796558e738e15ca0b8802e35f419a9fa374a978e41706f43c48b26", size = 219556, upload-time = "2026-10-30T15:07:08.538Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7b/c8ccb313439f4803779f186b8133563e6c49c023467a265ae52da7b7e25d/websockets-17.4-cp311-cp311-win_arm64.whl", hash = "sha256:ddab54dc0ec46ed09ef9ac131252cb5740ed21fbe6fc145f3c6fed0b575a8f15", size = 219487, upload-time = "2026-10-30T15:07:09.94Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/b020221345ea6c012dcf7496827a48a744b58696470b2b07fef95a32905a/websockets-17.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3d512e3a6c528c42ce220ed9c3caff83c6fdcd98a1b98249f08ccfc66ad111b1", size = 218779, upload-time = "2026-10-30T15:07:11.522Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/1c372ae7c032021a84e5c9e9b76b0dddefb354e9b4af783a97efd4d80ab3/websockets-17.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7390e060f830b3d132f484b21ac2783a2d227b682ef62bb8146e1c1b4ca0e45a", size = 216457, upload-time = "2026-10-30T15:07:13Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/cff249107473f149c6c916a49cba967379fa63b0a7d2dce6896d6848f0a3/websockets-17.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bcad49104711f88b2b7c3ba166de172857d837248205e541b044a552558fecc3", size = 216726, upload-time = "2026-10-30T15:07:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/77/5d/aff8ecbf1c55c4d96f6dacc46a52102895f58b85bb284c8d325aaf31606a/websockets-17.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf99debb17227a50620aee16647bb33a0387f5fa5418d628666c0f05745e0d2a", size = 226043, upload-time = "2026-10-30T15:07:15.937Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/6229d6583031ca81d092a0cd887a13a57b402fb555acc323d39b45967eb7/websockets-17.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ae1429ee9e52b139167ed0169757806a27ed7307e942e8c62037a9184e9843b", size = 226321, upload-time = "2026-10-30T15:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/7aba018fd53873e792a25afe1f4c005518133477b727d7455f2f7550c425/websockets-17.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51adfc30f467077293a6695d8149bcef501c0728386f3a491054f835dadb60b2", size = 227559, upload-time = "2026-10-30T15:07:18.998Z" }, + { url = "https://files.pythonhosted.org/packages/fb/66/37b207df9cdacaa017aa4487f3a9f2fcd668f6f7cfbafdac5b95ea258ee0/websockets-17.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c564d1e6531611bf87078c28780456ecc75459dcca189629fff2fe7c020d8e67", size = 230386, upload-time = "2026-10-30T15:07:20.431Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f6/87507e11fbae53817a435945273953b5b020589e309fac4fd60527ee5999/websockets-17.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c792e6cce24d08f92199d907785c9e64f74c357deeed3dbaf7d699ed5930b64", size = 228193, upload-time = "2026-10-30T15:07:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/90a04f0aa989b68fbd2efcd38237420afe2abe5a1cab0b5d7c2aa4f0dbb6/websockets-17.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c44776f82b04e623eb9e1b81fcd17ac6e9808ae1ba5cdf8edce1f7c869a95a9b", size = 226952, upload-time = "2026-10-30T15:07:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/50952af22b8e3e2d03f7e83f0e335851c6b484c3fd88586626fb1c2a34b3/websockets-17.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d2636a5b61c69e32db0e74b57073dd4a66f1ad195e9398c5f01bba41090deea", size = 224183, upload-time = "2026-10-30T15:07:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/2a4a5d9c6bca1127f24af7bf08f406a69052d7f341bc81f08d9e8eb446b4/websockets-17.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cfbbba4597f19ad717f79fd3f2a67ae7efae647bd6a61e14554e1b5ad242a5bf", size = 226991, upload-time = "2026-10-30T15:07:27.355Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/90d96a579839bfe541ef214315df81d2bd519d952847c303328518ffdd02/websockets-17.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c7b996c02de5e032cb63dc65482b73693b7ecaa586654126be2749ddea239c40", size = 225600, upload-time = "2026-10-30T15:07:28.973Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/12c15ed446d842e40918cd16e79b56ee4422d6fbf210258cb8882f50bbd5/websockets-17.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a920e99155d75d1b1c08172da8aaa7200e7b30871fb628d1631d7891a333e30e", size = 226366, upload-time = "2026-10-30T15:07:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/9d/73/cb4c927e806a73a0377696e031ef3c0db409028cdb2373f96ee863e8df8e/websockets-17.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8c00c5c49f80a588a2c4f99d519d2abed4ce26cdca11273041bc8b7e60af60de", size = 227470, upload-time = "2026-10-30T15:07:32.122Z" }, + { url = "https://files.pythonhosted.org/packages/33/b3/e682b3ea069933044f4bf4809eea0cd83e3a5bdbdff601d1777a9ae90795/websockets-17.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:52c31d123f5adf14dec694c2a4036bf42c523df7cefc641d31cd05653de56e49", size = 225026, upload-time = "2026-10-30T15:07:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e9/d039757260705e5a265a31c9fff36b0d4567b842ce2b54e5d14369f200bd/websockets-17.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0acd8191c0e5a61d1c27bfa33b6ec8431982299f5d71c02129aa4f16f182ad0", size = 226007, upload-time = "2026-10-30T15:07:34.954Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/cb9907fac6f9b28f93beded6efda2953960cd02b633d432acf3451590859/websockets-17.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a9cb5bbfc33b4ebd3c8d3714d6e09805437f60a14977774e4bd11b5ace46c23", size = 226286, upload-time = "2026-10-30T15:07:36.292Z" }, + { url = "https://files.pythonhosted.org/packages/39/99/85f6f4dd104a5d9a33a1ef272f501d0f2e8527ddf66be1b383d51f90ce56/websockets-17.4-cp312-cp312-win32.whl", hash = "sha256:c63c0d7832956d5f3bb77888b93c854569d13f806289ff75c43926d25c6e5fd2", size = 219256, upload-time = "2026-10-30T15:07:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/d1636ba67ee9232a60033d90483e48686aa3856014e59110f29015f97bd2/websockets-17.4-cp312-cp312-win_amd64.whl", hash = "sha256:dde9cd67f18803949ebf96de8aba12b8ec5ee3115e34c75a2db85a3b0a6c20d5", size = 219561, upload-time = "2026-10-30T15:07:39.19Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/e3859d52188f041a5a05c9f3ac005a9c829723f521f0fe60e2c5f9c93bb9/websockets-17.4-cp312-cp312-win_arm64.whl", hash = "sha256:c634053459278cf31fb404ddb5ecd38124e4609e8b609166da621edc2d5796db", size = 219487, upload-time = "2026-10-30T15:07:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/eaf27fce59e3f5b9997cd7c77749ef214e077daeffac95479be6ff9f5cfa/websockets-17.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f24bed98595b9d854da8d6debaf82754abf3e8fe63ec90505ef560566814df7e", size = 218769, upload-time = "2026-10-30T15:07:42.558Z" }, + { url = "https://files.pythonhosted.org/packages/13/8e/287eca6d399a953253492189153976676994dfba1b7e82f0f992d14c1404/websockets-17.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:156221ad7638b5abccefd6f798916bf3f8ffd6ac51480eb4ba5b05df914ccbf3", size = 216454, upload-time = "2026-10-30T15:07:43.874Z" }, + { url = "https://files.pythonhosted.org/packages/b3/65/098fd4d221d691395492fcba88c895dde19a51d371b06b8d0be71d3f781b/websockets-17.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74e46409666c358b93fce0d8c6f4cb107d8dd7cba1302a38d49adf6fa8193c7e", size = 216715, upload-time = "2026-10-30T15:07:45.409Z" }, + { url = "https://files.pythonhosted.org/packages/e9/29/9f9833bc83c1bf2e5597d1ef374d3ccd6467b9b45400cff399c85331ef6c/websockets-17.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3e80cad85ba68c07c1e8d4ccc9cf51e63bf6a73d6bf631640be947bd72b6b63", size = 226096, upload-time = "2026-10-30T15:07:46.929Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/6a95f93661bba2f1a4ec82eb26c6d30e6ba253569ecabb7a675139db6051/websockets-17.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:25eb661d8ba7e2b85ebb4e7a1bb9f6a3280910ed10b84330a7abfb5ed32d2764", size = 226382, upload-time = "2026-10-30T15:07:48.332Z" }, + { url = "https://files.pythonhosted.org/packages/80/b0/b0d9427dde0609e2727a9a966fb5fc5c6ef6aa7eba4cfc98791f8b7f1b75/websockets-17.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c74954409ea5789f7a60daa5f94580c00ad0b60b20751c47b35c4784877b5057", size = 227617, upload-time = "2026-10-30T15:07:49.799Z" }, + { url = "https://files.pythonhosted.org/packages/3b/00/fb1016d850d61676d1732434e2361d116c1de369078e59d21f4713115a19/websockets-17.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:47beedf07a706140b50786457e082497ee6b5970c41e49bcd1572ee3f4c73f80", size = 229054, upload-time = "2026-10-30T15:07:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/77/c4/bb666e196541e42237fdb625303ad099005cc835c4e2e0a5d97bb9c4185c/websockets-17.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e282f35ece8895b9fa5af406fc6a4cb42f01e1009525cf46641de584781c83b", size = 228243, upload-time = "2026-10-30T15:07:52.975Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f1/32ae0f45d8464e1606ef3f6a54318d8754b27889de18bb1b2176a19bea39/websockets-17.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:361f9a21a33191d5287e7a75f508fc3b9bf47aaffb0241198d6b7be3384f294e", size = 226995, upload-time = "2026-10-30T15:07:54.574Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9f/5680151ef10e0bfb6f3bf09a992ce6c3e93bbb84fc8ec5d03ad570ac6532/websockets-17.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4073ab7cefe5a2e7dc368c1f01eb27716dc8ea8f1c69e9b7725ebce05aa53cf7", size = 224244, upload-time = "2026-10-30T15:07:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/409117b6882b91960598d62b95fac33d70a7aa2e1e1e854c6b64e76712f6/websockets-17.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a50bd9b44dd9b7b845802030b4d88610c01c7f95571b4cbd3a1aeec17a34c9fd", size = 227058, upload-time = "2026-10-30T15:07:57.539Z" }, + { url = "https://files.pythonhosted.org/packages/35/e6/34cab5913bea9d011ae2592a7672340179ff14fb98e889e5e41b071d8134/websockets-17.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ad2e9e152bec6c4f7e4cdb4adcea6b708400090eec04bfe5ee5b30d368ffd721", size = 225654, upload-time = "2026-10-30T15:07:59.044Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b0/6cfe18e12f23618a3d69723c828037c1282fee7ffce006ae7b5cc0e58ca4/websockets-17.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5830f94a7190a33e55d06d89242ea951ee2009c3a27fc31538f3ac9386b27edd", size = 226425, upload-time = "2026-10-30T15:08:00.492Z" }, + { url = "https://files.pythonhosted.org/packages/c9/71/14d31ea34bafc07c6d96b362a44b301b9a0f2fb260490a8a6cb7db682bc4/websockets-17.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c0cc2cb47face4dae64bd70a9268dbebddb3e48c1b48cf6d7b6ac1471609fd88", size = 227532, upload-time = "2026-10-30T15:08:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/28/69/df3398ca1320a8b76e57ee41053d5c09955383cf60888ffa5771f51a4cc8/websockets-17.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d0b6a9bd86f865ebc9d847a1eb0a655a0e2b447cbcde743dda97c4298415cc0b", size = 225090, upload-time = "2026-10-30T15:08:03.328Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/06afe778cf9893e9ac10209c0d4c70aaf0b72289245a8b7efb92cc80927c/websockets-17.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7a7e9321822ca59ba56fa8744c3e3f01ef74d547aafe0d383d000d7a4d11eb79", size = 226041, upload-time = "2026-10-30T15:08:04.714Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7a/28e9822dbf0bc63341dc45d8d5f1f1f7f375991fc8895bc731105fbde371/websockets-17.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de29795f23833cdfb68ba45c8a5cc0ec49778ea5deb670188f4465bba5930d25", size = 226323, upload-time = "2026-10-30T15:08:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/606c9c404e82731e4520f6397c8c16549e9cbd39ac583e7ea7d8972a6ae7/websockets-17.4-cp313-cp313-win32.whl", hash = "sha256:37ed7782972545e67d97ef9a172e9499d25459b1e32cb99d1c2a30c572f2441b", size = 219248, upload-time = "2026-10-30T15:08:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/17e507dadbfccb755758ba977b165fec92924dfc4a8d2573b4fc1561b0cd/websockets-17.4-cp313-cp313-win_amd64.whl", hash = "sha256:203e42eb77e06f85778a908b34c5ad032f34e98a68ef33920d6685c1e65a9420", size = 219560, upload-time = "2026-10-30T15:08:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/01/ba/4f51fe8db73889ea3ecfdeee283d4ac3b7ad7a609c0e4c1fc6de04078021/websockets-17.4-cp313-cp313-win_arm64.whl", hash = "sha256:d8255971f6789cf0cb365e3e22b9b166eba978548a26241eaa5b7af0440d885a", size = 219496, upload-time = "2026-10-30T15:08:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5b/085530961668661300de7248f9bf3ba84e98f47875f0fcc87637d024f82b/websockets-17.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7", size = 218810, upload-time = "2026-10-30T15:08:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fb/5a29e9569f74988822b7bc076287dd7f9f5b68a2070cf5eb4cfada5863d8/websockets-17.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae", size = 216506, upload-time = "2026-10-30T15:08:13.561Z" }, + { url = "https://files.pythonhosted.org/packages/df/73/eb8544367c13cd87df39d41b74853741fb7182e2a4bb659ad7d36f9066c8/websockets-17.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd", size = 216719, upload-time = "2026-10-30T15:08:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/c3707e426d9bd779dfad9f319b2b431e5e60d563d7547d3780cbd1646028/websockets-17.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9", size = 226098, upload-time = "2026-10-30T15:08:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/d203439f909ffbf37260ca28a3432381412d2e79912ebf98be572e63055e/websockets-17.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa", size = 226449, upload-time = "2026-10-30T15:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/4b/17/3ae5d9486a170a222dd0437b4f2bc02ecf324177498a942dcac200e65d05/websockets-17.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e", size = 227695, upload-time = "2026-10-30T15:08:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/1307a8e30a961726f2350982fc01b0b3498db29b030c78c566e65ba64487/websockets-17.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5", size = 229069, upload-time = "2026-10-30T15:08:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bd/4c6167744e202d20a3c770bcde8fae5d427317b1885712c2e02718cd8133/websockets-17.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3", size = 228296, upload-time = "2026-10-30T15:08:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/14/b3/3438eee41b39afb44c3c6e45dd686b88a7f8d742b96877b77de4fe0e0255/websockets-17.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6", size = 227045, upload-time = "2026-10-30T15:08:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/0bac9052d402efaad1b202ff2f1a57b6334aa624d20f509312765601b507/websockets-17.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647", size = 224297, upload-time = "2026-10-30T15:08:26.275Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7f/5809f6ba5a7d1065301800daf605bdb661a9db5ef868ed189dbf691122ac/websockets-17.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4", size = 227169, upload-time = "2026-10-30T15:08:27.703Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f7/c8f6771e7cc2a038cf78bb12d04d29dbbbabf2a84d76999bcc94532f3020/websockets-17.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051", size = 225632, upload-time = "2026-10-30T15:08:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2f/9104786d952978ca2d011321109e35c05be24c3937ef2a62b4d6e25f7515/websockets-17.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e", size = 226415, upload-time = "2026-10-30T15:08:30.962Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a7/2e1606b3d616b86bb6c70a62d5f87fc9a3dbd81f0bbe24c4706ace76751d/websockets-17.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f", size = 227594, upload-time = "2026-10-30T15:08:32.443Z" }, + { url = "https://files.pythonhosted.org/packages/67/2d/78ca44e2be6d397aee63a4e68d3a99a33bb14bb3405e94688bc5a2160db5/websockets-17.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e", size = 225146, upload-time = "2026-10-30T15:08:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/6dad684e54fc8e09d70c9a095506db5c836207d8e846e3cb1e79424e3492/websockets-17.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48", size = 226095, upload-time = "2026-10-30T15:08:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/7b04bad7c11c1f92238429352defbac1406743338049afe1328c7a7f95c0/websockets-17.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f", size = 226395, upload-time = "2026-10-30T15:08:36.8Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/4eb29c4b12f77b3139470063308ae1e6a0860b9073b1d27d56e8ce19c512/websockets-17.4-cp314-cp314-win32.whl", hash = "sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5", size = 218784, upload-time = "2026-10-30T15:08:38.263Z" }, + { url = "https://files.pythonhosted.org/packages/6d/85/2496a24de80c215b55a15f037bc368a2ed0ce7ecb1e909039ff4e76395de/websockets-17.4-cp314-cp314-win_amd64.whl", hash = "sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8", size = 219137, upload-time = "2026-10-30T15:08:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a3/cfe95d4eb569035e5a1d2e5cbeb4ccf0eb15a19e1ef2ad819a85960bf375/websockets-17.4-cp314-cp314-win_arm64.whl", hash = "sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08", size = 219017, upload-time = "2026-10-30T15:08:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/2b/dd/4143048a2b27303e7de8ef53d7d0ee3987734fbf93e09491d5a1b0a55187/websockets-17.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac", size = 219141, upload-time = "2026-10-30T15:08:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/a63b5f3a5c24be2898eb22a3093a56d4ac4cd24ae79e7ab7a1b971ed4611/websockets-17.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83", size = 216675, upload-time = "2026-10-30T15:08:44.682Z" }, + { url = "https://files.pythonhosted.org/packages/d6/80/f238a10f0b23853c665c602a42784023995ac26674e480252c21af39f4f8/websockets-17.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452", size = 216880, upload-time = "2026-10-30T15:08:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b1/899b69aba1e8d65ac5bfe90b97ada22a815f1dcd3840bf188104738aac33/websockets-17.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df", size = 226462, upload-time = "2026-10-30T15:08:47.723Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/598c2b2a22dfe14d352400df804db8b78a68c1498b3f775a658f07f21618/websockets-17.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c", size = 226741, upload-time = "2026-10-30T15:08:49.314Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ad/84a6de3cc3dd4ee336428c31f89a82e002299f83e10d967f0e9a5cbaf591/websockets-17.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6", size = 227914, upload-time = "2026-10-30T15:08:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/69/ab/1e191b97d60a934c071b7b73c39c5db39721a925cc53acd929c615f85550/websockets-17.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e", size = 230059, upload-time = "2026-10-30T15:08:52.596Z" }, + { url = "https://files.pythonhosted.org/packages/f2/57/ef8a64732ef4367091ab05f9b22c1ee4c28657d4a0d088a644198eb5ded6/websockets-17.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866", size = 228486, upload-time = "2026-10-30T15:08:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/48/1f/dbad3afcacf2c52d1fdf83b611d849598f0a047d7333ff476266532877b8/websockets-17.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f", size = 227216, upload-time = "2026-10-30T15:08:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/5b/47/59e325d6ca3e04ba5cc7a139d891ef83cad3fb460a183c8f359f378b69a9/websockets-17.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a", size = 224755, upload-time = "2026-10-30T15:08:57.203Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b6/28a471318dfb0060b58c47d0a252cb5078528a1aa1fdf5e6e88e2a61cf18/websockets-17.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3", size = 227508, upload-time = "2026-10-30T15:08:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/ee/29/7485ac6fc4b342b43964811381f56276fec967cae6a851ae4b815613b10c/websockets-17.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d", size = 225961, upload-time = "2026-10-30T15:09:00.414Z" }, + { url = "https://files.pythonhosted.org/packages/95/a6/520e1f7aa9644ddf58f5a51fc37f46e15293a68cd35b70d5b81b7331ca1e/websockets-17.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7", size = 226851, upload-time = "2026-10-30T15:09:01.979Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0d/444799abe7418f361da8c201c6858b64b4e8d47265f90851e5cb68b4d90f/websockets-17.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b", size = 227869, upload-time = "2026-10-30T15:09:03.405Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/6da20322a07364a0ab0787a2b0d4367770f59404fdab6e35122413a7f63e/websockets-17.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1", size = 225562, upload-time = "2026-10-30T15:09:04.891Z" }, + { url = "https://files.pythonhosted.org/packages/2a/da/53e44d96238dc207011ad5d93b0540c58e1c680a86f48dc3fa8e5138b343/websockets-17.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e", size = 226334, upload-time = "2026-10-30T15:09:06.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/08/82cea972a87f229ee1903e9adf74c17d1e6c4371d581ebb11ccbed4e3a7b/websockets-17.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d", size = 226767, upload-time = "2026-10-30T15:09:07.837Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/ee6afff802e39e8d6ef29ee4bdc30f8272aaa38295c19a911fad91fe95fc/websockets-17.4-cp314-cp314t-win32.whl", hash = "sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35", size = 218918, upload-time = "2026-10-30T15:09:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/b0b07917f9c85a4d24a37300f4ae888bfa7c20f08902dd6a5c61a0d1b26f/websockets-17.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d", size = 219260, upload-time = "2026-10-30T15:09:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/19/a6/a63113909ea602d3d22b0f32a7a9950d8d18b725cfdce735eab9b5f5d198/websockets-17.4-cp314-cp314t-win_arm64.whl", hash = "sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed", size = 219176, upload-time = "2026-10-30T15:09:12.444Z" }, + { url = "https://files.pythonhosted.org/packages/ee/be/4032fde05e62d1c52edd250fda475e910cac0cd180e377690bff24147b5e/websockets-17.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6", size = 218740, upload-time = "2026-10-30T15:09:13.833Z" }, + { url = "https://files.pythonhosted.org/packages/74/1c/b47f5786eb45599072411571651606fb22f6e825d522d627daad974ea636/websockets-17.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16", size = 216470, upload-time = "2026-10-30T15:09:15.394Z" }, + { url = "https://files.pythonhosted.org/packages/70/98/0021f55de633617b4a8390cca17e700c9ba5d99323103128412dc2c15aca/websockets-17.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01", size = 216695, upload-time = "2026-10-30T15:09:17.185Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/2c67389b47c4b9e9e9318d9b3e4bb52a2fef3749ff00b0b151a342c4d42d/websockets-17.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e", size = 226134, upload-time = "2026-10-30T15:09:19.156Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/ee3a679a10741a0cdd7aa8bdd6d03999069672826aa7a6495fdf589ed961/websockets-17.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04", size = 226569, upload-time = "2026-10-30T15:09:20.587Z" }, + { url = "https://files.pythonhosted.org/packages/c4/42/b293a5d07099549797138d09fda5bf878d302b4980e61678981c8dfdc98f/websockets-17.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0", size = 227726, upload-time = "2026-10-30T15:09:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/d048ff83c4d0fc386f6008529d12690d0dcb410f73570430f13fe4d543f0/websockets-17.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb", size = 229426, upload-time = "2026-10-30T15:09:23.444Z" }, + { url = "https://files.pythonhosted.org/packages/e6/21/1df51f94047dfb76c32fd7508036e4b55352d0f04fd0e3bee525a2122225/websockets-17.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339", size = 228211, upload-time = "2026-10-30T15:09:24.902Z" }, + { url = "https://files.pythonhosted.org/packages/cf/13/33d6a28471da4c30a98a2313c76f984be2056a703c5c532225dc28f5dba3/websockets-17.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892", size = 227106, upload-time = "2026-10-30T15:09:26.315Z" }, + { url = "https://files.pythonhosted.org/packages/4a/86/ff8441e56ca677e51f9c826868fccde2a82bafe0ffa337fd38823b64253a/websockets-17.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b", size = 224499, upload-time = "2026-10-30T15:09:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/da/91/470d7a619ce4aa837ff5b6a5836227c43bdfdc42ef837cce48f508992438/websockets-17.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119", size = 227213, upload-time = "2026-10-30T15:09:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/55ed1422d6c73b5b81996ca195f2a256a339b26c553f5686afc2a926bc2b/websockets-17.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188", size = 225876, upload-time = "2026-10-30T15:09:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/e9/59/f133d260e7a48d3e231c3a495eeb6114b880c1e96bfc781a949c1f73991c/websockets-17.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921", size = 226403, upload-time = "2026-10-30T15:09:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/bb3355453ffdc31d3a14ddc0b50e058f13a97d2ed019ba6c380e03456169/websockets-17.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978", size = 227558, upload-time = "2026-10-30T15:09:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/f3d55a83c2a4daa8bf62594b00a721dc8767518ccb833f932b0178eb6c19/websockets-17.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6", size = 225369, upload-time = "2026-10-30T15:09:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/3a320bfb718d9ae1eff15ba7790979c0e6fdec74e388430f426e6647d7f4/websockets-17.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7", size = 226268, upload-time = "2026-10-30T15:09:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/1f9cdeeaaa2d5e158b7a40f971e74b641b35f977674b46229a5bb14e270a/websockets-17.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e", size = 226530, upload-time = "2026-10-30T15:09:39.171Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/506406ef09e6e168796f0187bdac0767f38a560e6785c4ee26e583aaaf76/websockets-17.4-cp315-cp315-win32.whl", hash = "sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57", size = 218751, upload-time = "2026-10-30T15:09:40.748Z" }, + { url = "https://files.pythonhosted.org/packages/25/a6/818647a9a9b3ee8bf131e9ab4fa1f2db9c6539f08e7084dd57549e0bd49d/websockets-17.4-cp315-cp315-win_amd64.whl", hash = "sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b", size = 219045, upload-time = "2026-10-30T15:09:42.251Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/0338a9abec3df8556627af987352657ce1c8456f2060b54741570b174122/websockets-17.4-cp315-cp315-win_arm64.whl", hash = "sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913", size = 218957, upload-time = "2026-10-30T15:09:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/22e83d003cdbd22be5d35e28c260203e9fbb6c73b17a8e1509f4ad49e74d/websockets-17.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d", size = 219056, upload-time = "2026-10-30T15:09:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ca/071d38e7237f6ada4f577b74f7cbf5bc2ff7e1f9360ebd2837e74a22dfd4/websockets-17.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a", size = 216630, upload-time = "2026-10-30T15:09:46.951Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/22d4523f92d88b8f5ebfd3df7ab5e825146af100f7e2caeae7d359ae51ea/websockets-17.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7", size = 216841, upload-time = "2026-10-30T15:09:48.285Z" }, + { url = "https://files.pythonhosted.org/packages/87/29/bc94be4a8bc19ef518e9efecd90a992f2991b7cfc6eb8a7430c4e2dc097c/websockets-17.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf", size = 226992, upload-time = "2026-10-30T15:09:49.776Z" }, + { url = "https://files.pythonhosted.org/packages/fa/25/58f69b5fec064764ddf5b88045397433cdb028f5b17c82fbe047fd31fff7/websockets-17.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70", size = 227258, upload-time = "2026-10-30T15:09:51.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/bc932a54c5f1bd78461574aea539fc562fd2de494738a2c0675c4de860be/websockets-17.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0", size = 228589, upload-time = "2026-10-30T15:09:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/57ec83066d0bfa8a9b9f594bc61951073c4a86e34c562a5292604118dab6/websockets-17.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463", size = 230217, upload-time = "2026-10-30T15:09:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fc/0f2fe533b5b4dc22ac704b3357f5c99fa60a3837218b3c6c6ad63a60120e/websockets-17.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de", size = 229026, upload-time = "2026-10-30T15:09:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/ed28307835106a9a2dbd1a5efc597e8befa3d0a5f2b46a4c1e466b2f4759/websockets-17.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9", size = 227904, upload-time = "2026-10-30T15:09:57.221Z" }, + { url = "https://files.pythonhosted.org/packages/39/4d/24ad7deddc7b31c3e430acc1c00c7b384ae42f1f973e9cc01aa42d19628f/websockets-17.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c", size = 225253, upload-time = "2026-10-30T15:09:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/f6/9c92230ebfab42fee482b236c45c32ed34b24f7360080b400cce6cfcac11/websockets-17.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7", size = 228089, upload-time = "2026-10-30T15:10:00.308Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7a/f523830e562da9b1d7a610174752753720c5e47e84b59b5fc47721d416bd/websockets-17.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad", size = 226743, upload-time = "2026-10-30T15:10:01.744Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e6/d1a56495cb7b1a0a4688145131be38cfd0c1342bde8125e136a6e6ee494e/websockets-17.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c", size = 227237, upload-time = "2026-10-30T15:10:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/44/41/c4e53f7ad63838d7eba6e82c5d743827e91dafe5e48c88a58f7e90043945/websockets-17.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556", size = 228348, upload-time = "2026-10-30T15:10:05.149Z" }, + { url = "https://files.pythonhosted.org/packages/ca/01/f6f78c3edeb8ad2b3c35a8c4117ec2cf9346215afaed8155af1e7933818b/websockets-17.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222", size = 226099, upload-time = "2026-10-30T15:10:06.588Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/d8c11740231b63d1aba6bad6b1d4e72e8384b26c5448801da238ebe82c92/websockets-17.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5", size = 226956, upload-time = "2026-10-30T15:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/87/dbfd43ae01ea8fdb7478a1b3d58b32aca83bb9c4aa61ebe187d1e188ac8e/websockets-17.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb", size = 227253, upload-time = "2026-10-30T15:10:09.533Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8e/fbd968a75428063560ada02d564de8d6c49ca34ec29dfb78e5d900f4b0ef/websockets-17.4-cp315-cp315t-win32.whl", hash = "sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe", size = 218877, upload-time = "2026-10-30T15:10:10.925Z" }, + { url = "https://files.pythonhosted.org/packages/f9/53/8f37a3943517bba77fc86f7c4162c455018c5b6d760c5e9c9a11de9e37f7/websockets-17.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca", size = 219195, upload-time = "2026-10-30T15:10:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/cf33cba7248fcd80cfe5911ffe67aa2637f50c2bfaada123b1e9d2aeca90/websockets-17.4-cp315-cp315t-win_arm64.whl", hash = "sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf", size = 219117, upload-time = "2026-10-30T15:10:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/a0/80/78c580302991d229ac5ad0a00c361916f7f84deaf6dd76370034dd8e7383/websockets-17.4-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:73b00645316121105274fd266ed54a20e6514650126d399cdea39876ab1e7b04", size = 216364, upload-time = "2026-10-30T15:10:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/7d18d381994c7c27a855131ae064ff43ec00ded636b7c6ead4abb4cec841/websockets-17.4-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:02f380df26de14999ecace203fe1396ac14d1c6132afc7924eb6f8c8b646c69d", size = 216642, upload-time = "2026-10-30T15:10:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/fc/91/43e73997fbdc7ef3cb434148888329113a07b66d97a8ea6aa2f93e574028/websockets-17.4-pp311-pypy311_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5b1d315a615cec81a79e2ce3a94221dc5c23e7fd4012897085d3b5e5a5638d0", size = 217587, upload-time = "2026-10-30T15:10:19.123Z" }, + { url = "https://files.pythonhosted.org/packages/a8/17/5e3e6be945512aff7351c9f60134803902ccb07cd7d6c9becdfa349a32d5/websockets-17.4-pp311-pypy311_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:046ed16d3d83d64594e4a40655fef061ccf36059d8c30a01feca8a9481fe13b8", size = 217478, upload-time = "2026-10-30T15:10:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5f/0a1ee98aeff04c200492a479a386ff106d1ef6d5c9d6613959b3e3d79469/websockets-17.4-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f772007dfba8941873dbd276b6a6845428cc740888b5c6c37bf483b1ea34b42", size = 218371, upload-time = "2026-10-30T15:10:22.35Z" }, + { url = "https://files.pythonhosted.org/packages/17/97/acb333fa9a54bb1ad616c1add65185f9ab580c3c76366a3d1abc1090979b/websockets-17.4-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:accb5e9492b9bc9c9aeec5b72ea8f2ddcbefc6143d1c626fba960ecbdc89807e", size = 306811, upload-time = "2026-10-30T15:10:23.76Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/d8fbc72c5c0e78905caf2d6dcf806e532451e3f48e2a09a8ebe1de9ad412/websockets-17.4-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:c7f66a53e9b7ec831abb15e3d9b6cee8f52d6a24cd0a374786bf46c3ec218bc6", size = 216397, upload-time = "2026-10-30T15:10:25.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/1bf313cd0f4662df4884a7602932cd13347090e59dd6047865eee2bb5aa5/websockets-17.4-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:4fb2db1f649b6464711846b5bb9c1611bd906ce939d9460cbd1fa419552cd6c3", size = 216664, upload-time = "2026-10-30T15:10:27.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/e2/7912bd417511b54907853bd495aef2daee111062dbafd43d133a3f7253a7/websockets-17.4-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:32110d954d9d0e51f36edb0396673bf4d35193a0a80ba7013bf7f7d58a33ca07", size = 217603, upload-time = "2026-10-30T15:10:28.475Z" }, + { url = "https://files.pythonhosted.org/packages/56/6f/2e8d5610f6184a2e1720bf8ce256402ed99ad30ea87c461fba5bc4d651d9/websockets-17.4-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88ba8a439b2bad97bb69c38db5bf0ec16a061ee834cf0f244089186ee803e899", size = 217489, upload-time = "2026-10-30T15:10:29.999Z" }, + { url = "https://files.pythonhosted.org/packages/29/3a/a05bc57eadcd7e8e072d917888128e6d22e1203984afb21a8da3198982fe/websockets-17.4-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:868744c68e9342768888c7326604da2228c19bb6946aa46b5f9e1ac32ea8d40b", size = 218356, upload-time = "2026-10-30T15:10:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/b2/49/eed86bfb8863093a82cbaa52c9159be1ce2374d5ace6fe1f8e8031567e7d/websockets-17.4-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:829bcaa90be8737c132d9515d6a466e8528e3b3d050d3f3466f36ea94d3beb48", size = 306838, upload-time = "2026-10-30T15:10:32.954Z" }, + { url = "https://files.pythonhosted.org/packages/eb/27/9db924cb0dbf22eade78d98fbae77790f9bf6bc729e6ab29aef9d68e4156/websockets-17.4-py3-none-any.whl", hash = "sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9", size = 212900, upload-time = "2026-10-30T15:10:34.482Z" }, ] [[package]] name = "zipp" -version = "4.1.1" +version = "4.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/23/655a1802fe8041302c959774ca7c80b53bc24737ff3ef45cb50ef11bd96c/zipp-4.1.1.tar.gz", hash = "sha256:7ebb7a44c021b29fd8dbd7cce6812d0d7b5b454521f93cc71af6ccd155aaa70b", size = 27649, upload-time = "2026-10-03T17:03:03.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/05/ba1458dee0ac485b2a01071c1b88f5199fe878a485df482e10e1ef2205cc/zipp-4.1.2.tar.gz", hash = "sha256:cf474b90b07ea932637e17c6b9479de2b2882900b0d311bdaf4f5c000a15a97f", size = 27864, upload-time = "2026-10-21T18:57:04.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/98/df615823cd9419131ce19fba00de53a663794369e198aade064a244b385d/zipp-4.1.1-py3-none-any.whl", hash = "sha256:8979f52d874162f485ff2981e3891f3a3317b7a3dd43ff1e1775b9304f307a9c", size = 10582, upload-time = "2026-10-03T17:03:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/b8/44/7bd2007260da11cccca29ba2d4c345f1287aa663db38ee7cefeabf6386a5/zipp-4.1.2-py3-none-any.whl", hash = "sha256:b30cb6cd253122169c47de245ca24a86bac2a5d3802ba15bf7ba6414eb9e144d", size = 10581, upload-time = "2026-10-21T18:57:03.79Z" }, ] diff --git a/scripts/build_http_plugin.py b/scripts/build_http_plugin.py index 5c20868e..614eaf32 100644 --- a/scripts/build_http_plugin.py +++ b/scripts/build_http_plugin.py @@ -28,14 +28,14 @@ ROOT = Path(__file__).resolve().parents[1] ASSETS = ROOT / "scripts" / "http_plugin_assets" -PLUGIN_NAME = "dataiku-headless-http" +PLUGIN_NAME = "headless-http" COPY_PATHS = ( "LICENSE", "skills", "docs/assets", - ".mcp.json", ".codex-plugin", ".claude-plugin", + ".github/plugin", ) @@ -62,11 +62,12 @@ def write_json(path: Path, value: dict) -> None: def update_manifests(destination: Path, *, endpoint: str) -> None: """Apply the HTTP-specific differences to copied source manifests.""" - mcp = read_json(destination / ".mcp.json") - mcp["mcpServers"]["dataiku"] = {"type": "http", "url": endpoint} - write_json(destination / ".mcp.json", mcp) - codex = read_json(destination / ".codex-plugin" / "plugin.json") + mcp_path = destination / codex["mcpServers"] + mcp = read_json(mcp_path) + mcp["mcpServers"] = {"dku": {"type": "http", "url": endpoint}} + write_json(mcp_path, mcp) + codex["name"] = PLUGIN_NAME codex["interface"]["displayName"] = "Dataiku Headless (HTTP)" write_json(destination / ".codex-plugin" / "plugin.json", codex) @@ -74,13 +75,25 @@ def update_manifests(destination: Path, *, endpoint: str) -> None: claude = read_json(destination / ".claude-plugin" / "plugin.json") claude["name"] = PLUGIN_NAME claude["displayName"] = "Dataiku Headless (HTTP)" - claude["mcpServers"]["dataiku"] = {"type": "http", "url": endpoint} + claude["mcpServers"] = {"dku": {"type": "http", "url": endpoint}} write_json(destination / ".claude-plugin" / "plugin.json", claude) marketplace = read_json(destination / ".claude-plugin" / "marketplace.json") marketplace["plugins"][0]["name"] = PLUGIN_NAME + # This catalog distributes HTTP only; do not inherit stdio rename history. + marketplace["renames"] = {"dataiku-headless-http": PLUGIN_NAME} write_json(destination / ".claude-plugin" / "marketplace.json", marketplace) + github_path = destination / ".github" / "plugin" + github = read_json(github_path / "plugin.json") + github["name"] = PLUGIN_NAME + github["mcpServers"] = {"dku": {"type": "http", "url": endpoint}} + write_json(github_path / "plugin.json", github) + marketplace = read_json(github_path / "marketplace.json") + marketplace["plugins"][0]["name"] = PLUGIN_NAME + marketplace["renames"] = {"dataiku-headless-http": PLUGIN_NAME} + write_json(github_path / "marketplace.json", marketplace) + def populate_plugin(destination: Path, *, endpoint: str) -> None: """Copy shared plugin content and overlay the HTTP-specific assets.""" @@ -116,12 +129,15 @@ def render(output: Path, *, endpoint: str, archive: bool) -> Path: temporary_root = Path( tempfile.mkdtemp(prefix=f".{output.name}-", dir=artifact.parent) ) - staged_plugin = temporary_root / PLUGIN_NAME + staged_plugin = temporary_root / "dataiku-headless-http" try: populate_plugin(staged_plugin, endpoint=endpoint) if archive: staged_archive = shutil.make_archive( - str(temporary_root / output.name), "zip", temporary_root, PLUGIN_NAME + str(temporary_root / output.name), + "zip", + temporary_root, + staged_plugin.name, ) Path(staged_archive).rename(artifact) else: diff --git a/scripts/check_release.py b/scripts/check_release.py index 8d8f280c..74dada73 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -37,10 +37,12 @@ def validate(root: Path) -> str: f".{name}-plugin/plugin.json" for name in ("claude", "codex", "cortex", "cursor") } + required.add(".github/plugin/plugin.json") for relative in required: if not (root / relative).is_file(): raise ValueError(f"Missing release manifest: {relative}") manifests = sorted(root.glob(".*-plugin/plugin.json")) + manifests.append(root / ".github/plugin/plugin.json") if (root / "plugin.json").exists(): manifests.append(root / "plugin.json") configured = project["tool"]["commitizen"]["version_files"] @@ -50,11 +52,18 @@ def validate(root: Path) -> str: raise ValueError(f"{relative} is missing from Commitizen version_files") if json.loads(path.read_text())["version"] != version: raise ValueError(f"{relative} must carry version {version}") - marketplace = json.loads((root / ".claude-plugin/marketplace.json").read_text()) - for entry in marketplace["plugins"]: - if entry["name"] == project["project"]["name"]: - if entry.get("version", version) != version: - raise ValueError("Marketplace version differs from project version") + for relative in ( + ".claude-plugin/marketplace.json", + ".github/plugin/marketplace.json", + ): + plugin_name = json.loads( + (root / relative).with_name("plugin.json").read_text() + )["name"] + marketplace = json.loads((root / relative).read_text()) + for entry in marketplace["plugins"]: + if entry["name"] == plugin_name: + if entry.get("version", version) != version: + raise ValueError("Marketplace version differs from project version") return version diff --git a/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md b/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md index 9ebbe3ac..f3aa6c71 100644 --- a/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md +++ b/scripts/http_plugin_assets/skills/dataiku-headless-setup/SKILL.md @@ -12,11 +12,11 @@ Use this workflow for the customer-managed Dataiku Headless Streamable HTTP serv 1. Check whether the Dataiku MCP tools are available. If they are, run `list_instances`. 2. If the tools are unavailable, or `list_instances` reports that authentication is required, explain that the remote server needs OAuth login and ask whether the user wants to authenticate now. 3. Only after the user agrees, use the authentication flow for the current client: - - Codex app or IDE extension: direct the user to **Settings → MCP servers → dataiku → Authenticate**. - - Codex CLI: run `codex mcp login dataiku` when local commands are available; otherwise ask the user to run it. - - Claude chat or Claude Desktop: direct the user to **Customize → Connectors → dataiku → Connect**. - - Claude Code CLI: run `claude mcp login dataiku` when local commands are available; otherwise ask the user to run it. - - Claude Code desktop local session or IDE extension: direct the user to `/mcp`, then have them select `dataiku` and authenticate. + - Codex app or IDE extension: direct the user to **Settings → MCP servers → dku → Authenticate**. + - Codex CLI: run `codex mcp login dku` when local commands are available; otherwise ask the user to run it. + - Claude chat or Claude Desktop: direct the user to **Customize → Connectors → dku → Connect**. + - Claude Code CLI: run `claude mcp login plugin:headless-http:dku` when local commands are available; otherwise ask the user to run it. + - Claude Code desktop local session or IDE extension: direct the user to `/mcp`, then have them select `plugin:headless-http:dku` and authenticate. 4. Have the user complete the browser-based sign-in. Reload or restart the client if its tools do not reconnect, then retry `list_instances`. 5. HTTP catalogs are administrator-managed. If no instance is active, ask the user which listed instance to use and call `switch_instance`. Never call `configure_instance` or `delete_instance`. 6. Call `get_current_instance` to verify delegated Dataiku access and capture the Dataiku version when available. diff --git a/skills/dataiku-headless-setup/SKILL.md b/skills/dataiku-headless-setup/SKILL.md index 14273c34..b4f70a13 100644 --- a/skills/dataiku-headless-setup/SKILL.md +++ b/skills/dataiku-headless-setup/SKILL.md @@ -20,9 +20,56 @@ Bring a new or broken plugin installation to a verified Dataiku connection. A re - macOS or Linux: `uv run --quiet --locked --script "/runtime/run_mcp.py" --transport stdio < /dev/null` - Windows PowerShell: `$null | uv run --quiet --locked --script "\runtime\run_mcp.py" --transport stdio` - A startup line on stderr followed by exit status 0 is expected. Do not substitute `uv sync --locked --script`: it caches downloads but leaves environment creation for the first server launch. -7. Retry `list_instances`. If the tools are still unavailable, reload the plugin or restart the agent once, then retry before diagnosing a Dataiku connection problem. -8. When `list_instances` succeeds, if no instance is configured, run `configure_instance` and have the user complete the local setup page. If multiple instances exist without an active one, ask which to use and run `switch_instance`. -9. Verify the active profile with `get_current_instance`. If its `connection_status` is `failed`, the discovered profile cannot connect to Dataiku; run `configure_instance` to replace or add a working profile instead of treating it as set up. When it is `connected`, make a lightweight read-only Dataiku call such as `list_projects` to validate the available Dataiku access. Never request or repeat the API key in chat. + A startup line on stderr followed by exit status 0 is expected. This checks standalone runtime startup, not the agent's MCP registration or connection. Do not substitute `uv sync --locked --script`: it caches downloads but leaves environment creation for the first server launch. +7. Retry `list_instances`. If the tools are still unavailable, reload the plugin or restart the agent once, then retry. If they are still missing, inspect the agent's MCP registration and startup error; do not repeat the warm-up/restart loop. Check the executable, script path, working directory, and any organization policy error before diagnosing a Dataiku connection problem. +8. When `list_instances` succeeds, if no instance is configured, run `configure_instance` and have the user add an instance in the local browser manager, test it when supported, save it, and select **Done**. Design and Automation require a successful connection test. Agent Management, Govern and Deployer testing is unavailable; save their locally validated profiles directly. Select **Switch to this instance after saving** to make it active. The manager stays open across operations; saving alone does not complete URL elicitation. If multiple instances exist without an active one, ask which to use and run `switch_instance`. +9. Verify the active profile with `get_current_instance`. If its `connection_status` is `failed`, inspect the returned `error` before continuing. For SSL/TLS or certificate failures, follow [HTTPS certificate trust](#https-certificate-trust) before changing the profile. For other failures, diagnose the reported cause and use `configure_instance` when the profile needs correction; do not treat it as set up. If it is `unsupported`, the Agent Management, Govern or Deployer profile is configured but its API tools are not implemented; explain this and offer to switch to a supported instance instead of repeating setup or probing DSS. When it is `connected`, make a lightweight read-only Dataiku call such as `list_projects` to validate the available Dataiku access. Never request or repeat the API key in chat. -Report completion with the active instance name, URL, and configured type, Dataiku version when available, and whether the Dataiku read succeeded. Include the uv version only when runtime recovery was needed. If a restart is required, say that setup is incomplete and give the single next action. +Report completion with the active instance name, URL, and configured type, Dataiku version when available, and whether the Dataiku read succeeded or the selected node type has no API tools yet. Include the uv version only when runtime recovery was needed. If a restart is required, say that setup is incomplete and give the single next action. + +## HTTPS certificate trust + +The standard launcher trusts CAs in the operating system certificate store. If +HTTPS verification fails, diagnose the cause before recommending a configuration +change. Inspect the certificate and chain presented by the Dataiku endpoint for +hostname mismatches, expired or not-yet-valid certificates, and missing +intermediate certificates. Consider whether a corporate proxy is presenting its +own certificate. + +When the failure points to an untrusted issuer, inspect the relevant trust +configuration on the machine or container running the MCP server. If local tools +or access are unavailable, explain what remains unknown. + +Report the finding and a concrete next step: for example, ask the Dataiku +administrator to renew the certificate or correct its chain, or ask IT to verify +that the required CA is installed and trusted. Do not install certificates or +change trust settings without authorization. + +If immediate access is needed, explain that **Skip certificate verification** is +available as a temporary workaround, but is discouraged because it prevents +reliable server authentication and could expose credentials and data to +interception. Do not enable it automatically. After the certificate issue is +resolved, re-enable verification, test, and save the profile. + +## Manage existing instances + +Use `configure_instance` to reopen the local instance manager. It groups instances +by type and supports create, edit, delete, **Use now**, and **Set default**. **Use now** +changes the target of later tool calls; **Set default** selects the saved startup +profile and does not switch the active instance. Environment and Code Studio +instances remain read-only and take precedence at startup. HTTP instances remain +platform-managed and cannot use this local manager. + +When editing, a saved API key is indicated by a `**********` placeholder; the +input itself remains blank. Leave it blank to preserve the saved key or ticket. A new +API key replaces the credential. For Design and Automation, a successful connection test is required before +saving; changing URL, credential, certificate policy, or type requires a new test. +Connection-test failures appear in the editor's connection-status area. Expand **Error details** +to inspect the underlying exception before retrying; for SSL/TLS failures, follow +[HTTPS certificate trust](#https-certificate-trust). +Deleting an active/default profile requires choosing another saved profile when +one is available. It removes only the saved instance configuration. The Dataiku +instance and its data stay in place. +After **Done**, verify the active instance with `get_current_instance`; never +infer activation from saving or choosing a default. Changes are applied immediately +and survive page expiry. The manager expires after ten minutes; reopen it if needed. diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 93367986..1353336c 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -10,7 +10,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t ## Shared Operating Rules 1. If the user asks to install, set up, connect, or repair Dataiku Headless, or its MCP tools are unavailable just after installation, read `../dataiku-headless-setup/SKILL.md`. Choose either local stdio or customer-managed HTTP setup and never enable both. -2. Ensure an instance is configured before any Dataiku work. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. The required `instance_type` is configured metadata, not API verification or a guarantee of client support. +2. Ensure an instance is configured before instance operations. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. Check the configured `instance_type` against the policy below before choosing a tool. It controls dispatch; Dataiku permissions and API availability still apply. 3. Discover project keys and object identifiers through tools; do not invent them. 4. Read before write. Inspect the current object, flow context, jobs, or run history before changing anything. 5. Treat the matching reference guide as the source of truth for object-specific concepts, inspection steps, and required references. @@ -20,6 +20,27 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t 9. Preserve surrounding flow, storage, and operational context unless the user requests a change. 10. If MCP or Cobuild coverage is insufficient, stop and report the gap rather than falling back to raw Python, `dataikuapi`, or ad hoc REST calls. Attribute the gap to the Dataiku version only when that requirement is known; otherwise, do not guess. +## Instance Types + +| Configured type | Available work | +| --- | --- | +| `design` | All tools | +| `automation` | Reads, execution of existing assets, instance administration, and all project-folder operations | +| `agent-management`, `govern`, `deployer` | Instance controls only: list, switch, configure, delete, and current profile | + +Automation excludes all Cobuild tools, including conversation listing and polling, +and project creation, project settings/variables updates, container placement, +dataset/folder creation, file upload, and library writes. Builds, recipe/scenario +runs, and job aborts remain available and can change outputs. For project +construction or edits, ask which Design instance to use and +switch to it; do not bypass the policy through raw APIs. + +Agent Management, Govern and Deployer report `connection_status=unsupported` without a DSS probe. +Explain that their API tools are not implemented, then switch to a supported +configured instance for Dataiku work. Local configuration actions remain subject +to the existing HTTP restrictions. The catalog stays visible across all types; +unsupported calls fail before execution. + ## Default Workflow 1. Identify the target project and the user's main object of work. @@ -30,7 +51,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t 6. If the task is a documented direct-write exception, follow the matching guide's direct-write path instead of forcing it through Cobuild. 7. Keep the user's main object of work as the primary guide. Read supporting guides only when the primary guide tells you they are relevant. 8. For tasks that span multiple object types, keep one primary guide and read supporting guides for dependent objects or job/run follow-up. -9. Completion status is not enough. Validate by re-reading the changed object and using `./references/jobs.md` when execution may still be in progress. +9. Completion status is not enough. Validate by re-reading the changed object and comparing it with what the user asked for. Where the request is specific about names, titles, or values, check them in the saved settings or a data sample, and mention any requirement you could not confirm. Use `./references/jobs.md` when execution may still be in progress. ## Guide Routing @@ -47,6 +68,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t | Inspect managed folders, create one on a chosen connection, or upload a user-supplied local file into one | `./references/managed_folders.md` | | Inspect the project library or write one user-supplied local source file | `./references/project-libraries.md` | | Inspect connections or choose a valid connection | `./references/connections.md` | +| Create, extend, test, or package a Dataiku plugin from local source | `./references/plugin-development.md` | | Dataiku administrative tasks: (e.g. users, groups, code environments, general settings, etc.) | `./references/administration.md` | | Inspect ML analyses, trained models, or saved models | `./references/machine-learning.md` | | Inspect LLMs, Knowledge Banks, or RAG LLMs | `./references/llms-and-knowledge-banks.md` | diff --git a/skills/dataiku-headless/references/administration.md b/skills/dataiku-headless/references/administration.md index 495d8e68..90c17e9a 100644 --- a/skills/dataiku-headless/references/administration.md +++ b/skills/dataiku-headless/references/administration.md @@ -16,5 +16,6 @@ direct MCP tools. Permissions vary by operation; follow the matching guide. | List, create, update, or delete instance groups | `./administration/groups.md` | | Inspect license validity, expiry, or profile capacity | `./administration/licensing.md` | | Inspect, create, update, or delete code environments | `./administration/code-environments.md` | -| List installed plugins, update one from the store or a local path, or delete one | `./administration/plugins.md` | +| Discover store plugins, manage installed plugins, or prepare their managed code environments | `./administration/plugins.md` | +| Create, extend, test, or package a Dataiku plugin from local source | `./plugin-development.md` | | Inspect container execution or Spark configurations | `./administration/general-settings.md` | diff --git a/skills/dataiku-headless/references/administration/code-environments.md b/skills/dataiku-headless/references/administration/code-environments.md index c32e7af0..3c50bb66 100644 --- a/skills/dataiku-headless/references/administration/code-environments.md +++ b/skills/dataiku-headless/references/administration/code-environments.md @@ -38,7 +38,7 @@ Matching a workload's language does not establish package or runtime compatibili - Python package entries are requirements-style lines. R entries use Dataiku raw package-spec lines, for example `"RJSONIO","1.3"`. - Package filtering accepts names only, not version constraints or extras. It matches declared/requested package names—not transitive packages installed while resolving them—and every requested name must match. Matches are case-insensitive; Python also treats hyphens, underscores, and dots as equivalent. Inspect `requested_packages` for declared version constraints, but do not treat them as resolved installations or proof of runtime compatibility. -- Package-spec changes are supported only for `DESIGN_MANAGED` environments. Non-`DESIGN_MANAGED` deployment modes are `DSS_INTERNAL`, `PLUGIN_MANAGED`, `PLUGIN_NONMANAGED`, `BUSINESS_APP_MANAGED`, `DESIGN_NON_MANAGED`, and `EXTERNAL_CONDA_NAMED`. A `PLUGIN_MANAGED` environment belongs to a plugin: its packages come from the plugin's own specification, so rebuild a bound environment through `./plugins.md`. Initial creation and binding must be handled in the Dataiku UI. +- Package-spec changes are supported only for `DESIGN_MANAGED` environments. Non-`DESIGN_MANAGED` deployment modes are `DSS_INTERNAL`, `PLUGIN_MANAGED`, `PLUGIN_NONMANAGED`, `BUSINESS_APP_MANAGED`, `DESIGN_NON_MANAGED`, and `EXTERNAL_CONDA_NAMED`. A `PLUGIN_MANAGED` environment belongs to a plugin: its packages come from the plugin's own specification. Follow `./plugins.md` for initial creation and binding with `ensure_plugin_code_env`, or to rebuild a bound environment. - Owner, `usable_by_all`, and group permissions are supported. Supplying group permissions replaces the full group permission list. A group permission item has the form `{"group": "data-science", "use": true, "update": false, "manage_users": false}`. - Container execution and Spark Kubernetes build targets are supported. Supplying any target during creation builds the resulting images; target changes rebuild images automatically on update. - `all_container_configurations` and `all_spark_kubernetes_configurations` take precedence over any listed configurations; listed values are preserved but ignored by Dataiku while their corresponding `all_*` value is true. diff --git a/skills/dataiku-headless/references/administration/plugins.md b/skills/dataiku-headless/references/administration/plugins.md index 5b0a8ed9..1d7a7f4d 100644 --- a/skills/dataiku-headless/references/administration/plugins.md +++ b/skills/dataiku-headless/references/administration/plugins.md @@ -1,14 +1,17 @@ --- name: plugins -description: Discover installed Dataiku plugins, update one from the plugin store or a local path, and remove it. +description: Discover store plugins, manage installed Dataiku plugins, and set up their managed code environments. --- # Plugins -Use this guide when the user asks what plugins an instance has, wants one updated or -removed. Plugin management is a direct-write exception: it is +Use this guide to find store plugins, inspect installed plugins, install, update, or +remove one, or prepare its initial code environment. Plugin management is a direct-write exception: it is instance-wide, not an in-project asset, and Cobuild cannot perform it. +To build or update the plugin itself, start with +[Plugin development](../plugin-development.md), then return here to install it. + Plugin operations need administrator permission, permission to develop plugins, or administrator rights on the specific plugin. Dataiku enforces this. @@ -28,36 +31,119 @@ for that plugin by name; someone is working in it. "the SharePoint plugin" or "Answers" resolve without knowing the id. 2. Use `search_mode="exact"` with `include_details=true` to inspect one plugin before changing it. Details cost one settings read per row, so pair them with `search`. -3. In stdio, update an installed plugin with `update_plugin`, or remove it with `delete_plugin`. `update_plugin` is unavailable in Streamable HTTP because plugin sources can be local server paths. -4. `code_env_name: null` means no environment is bound; it does not tell you whether - one is required. Check requirements and perform initial setup in the Dataiku UI. -5. After any change, re-read the plugin and report `needs_restart` when it is true. - -## Update sources - -Use the installed id returned by `list_plugins`. A `source="store"` update requires -that the plugin came from the store. These tools do not browse the store catalog or -install new plugins; use the Dataiku UI for installation. +3. Use `install_plugin` for a new plugin or `update_plugin` for an existing one, and + `delete_plugin` to remove one. `source="local_path"` works only in stdio, because + it reads the server's filesystem. Store and Git sources work in both transports. +4. After installation, read `code_env.status`: `required` when a local archive has + `code-env/python/desc.json` or `code-env/R/desc.json`, `not_required` when it has + neither, and `unknown` for store or Git installs. For `required` or `unknown`, call + `ensure_plugin_code_env` as described under Code environments. +5. Verify with `list_plugins(search=plugin_id, search_mode="exact")`: after an + install or update, confirm the installed id and returned version; after deletion, + confirm the id is absent. After environment setup, add `include_details=true` and + confirm `code_env_name` matches the setup result, then read `build.status`. + Report `needs_reload` and `needs_restart` when true. Installation and environment + setup alone do not show that the components work. + +## Sources + +### Store discovery + +`list_plugins` searches installed plugins. To discover store plugins and releases, +fetch the public catalog with the agent's web or HTTP tools: + +1. Read the target Dataiku version with `get_current_instance`, or use the version + the user is planning for. +2. Fetch `https://update.dataiku.com/dss/{major_version}/plugins/list.json`: + [Dataiku 15](https://update.dataiku.com/dss/15/plugins/list.json) or + [Dataiku 14](https://update.dataiku.com/dss/14/plugins/list.json), for example. +3. Search `items` by `id`, `meta.label`, `meta.description`, or `meta.tags`. Read + `storeVersion` for the offered version and `revisions` for listed releases and + release notes. Check `storeFlags.downloadable` and the plugin's documentation + for requirements specific to a minor version or environment. +4. Use the catalog's exact `id` for installation; never guess it. For updates, use + the installed ID from `list_plugins` and compare its version with `storeVersion`. + +A `source="store"` update requires that the plugin came from the store. Store calls +do not accept a release version; listing a revision does not select it for installation. + +### Local archives For `source="local_path"`, a ZIP may hold `plugin.json` at its root or inside one wrapper directory — what a GitHub "Download ZIP" produces — and the wrapper is dropped before upload. The archive's own manifest names the target, so you cannot update the wrong plugin by mistake. -There is no Git update tool: that needs Git authentication configured on the -Dataiku instance, which cannot be arranged from here. Clone the repository locally and -use `source="local_path"`, or do it in the Dataiku UI. +### Git sources + +Use `install_plugin(source="git", repository_url=..., checkout=...)` to have Dataiku +fetch a repository. Choose an explicit branch, tag, or commit; use a commit for a +reproducible installation. `subpath` is the UI's **Path in repository**: set it to +the relative folder containing `plugin.json` (for example, `plugins/my-plugin`), +or omit it for the repository root. +Omit `plugin_id` and `local_path`: the remote manifest determines the installed ID. +This installs a packaged plugin, not an editable development plugin. + +For a Git update: + +1. Use the local agent's Git or GitHub access to read `plugin.json` at the chosen + branch, tag, or commit and `subpath`. +2. Confirm its ID matches the installed plugin. +3. Call `update_plugin(source="git", plugin_id=..., repository_url=..., + checkout=...)` with the same revision and `subpath`. Use a commit SHA when you + need to ensure the revision cannot move after inspection. +4. Verify the installed ID and version. The tool also checks Dataiku's reported ID + before starting any requested environment rebuild. + +If the agent cannot read the repository, explain which access is missing and help +the user set it up before updating. Local Git access is separate from Dataiku's +access described below. + +### Git access + +Dataiku fetches repositories using its own Git configuration. Logging into GitHub +or configuring Git on the Headless machine does not provide that access. Public +repositories may need no credentials; private repositories need read access. + +If access fails, help the user check the following in the +[Git setup guide](https://doc.dataiku.com/dss/latest/collaboration/git.html) +for their Dataiku version: + +- An administrator must allow the repository in **Administration > Settings > Git**. +- Where per-user SSH keys are supported and enabled, use **Profile > Credentials > + SSH** and register the public key with the Git provider. Enter private keys and + passphrases only in Dataiku's credential UI. +- For admin-managed SSH or HTTPS, an administrator configures credentials on the + Dataiku server. Access must work without interactive prompts; SSH host keys must + be validated. + +Authentication failures suggest missing credentials or repository permission. +“Repository not found” can also mean a wrong URL or a private repository the user +cannot access. Check host-key, network, checkout, and subpath errors separately. +Never ask for secrets in chat or put tokens in repository URLs. Headless does not +configure Git credentials. ## Long-running operations -Each update or deletion response carries `status` — `started`, `still_running`, `completed`, or `refused` — -plus the `operation` it describes. +Each install, update, or deletion response carries `status` — `started`, `still_running`, `completed`, or `refused` — +plus the `operation` it describes. Git operations can also return +`verification_required`: Dataiku reported completion, but the installed plugin +could not be verified. Inspect `list_plugins` before retrying or preparing its environment. - `started` and `still_running` mean the work is in flight. Follow the returned - `future_id` per `../jobs.md`, and **do not start a duplicate operation**. + `future_id` per [Jobs](../jobs.md), and **do not start a duplicate operation**. + For Git installation, the plugin ID may only become known on completion. If you + fetch a Git operation's result yourself, check `pluginDesc.desc.id` (against the + expected ID for updates) and verify it with `list_plugins`. +- Git errors and plugin future results omit raw subprocess output, logs, and settings, + which may contain secrets. Follow any + returned future ID or inspect installed plugins before retrying after a failure. - If `code_env_rebuild.status` is `not_started`, follow the update to successful completion, then ask the user to rebuild in the Dataiku UI. If the rebuild is already running, follow its own `future_id`. +- If `code_env_rebuild.status` is `unknown`, the plugin update succeeded but the + rebuild outcome could not be verified. Follow its `future_id` when present; + otherwise inspect the bound environment in Dataiku before rebuilding. - Do not repeat `update_plugin` solely to start or retry an environment rebuild. - Dataiku reports a failed action as an ordinary successful response, so the absence of an error is not evidence of success. Update and delete check the reported @@ -66,23 +152,39 @@ plus the `operation` it describes. ## Code environments -- Initial plugin code-environment creation and binding are out of scope. The tools - report a bound environment name but do not establish whether an unbound plugin - requires one. Use the Dataiku UI to inspect requirements and complete setup; do not - substitute `create_code_env` for plugin-managed environment creation. -- `update_plugin(rebuild_code_env=true)` updates the plugin, then rebuilds its - **already-bound** environment from the plugin's specification. It does not create or - bind an environment. If none is bound, `code_env_rebuild.status` is `skipped` and the - response directs you to the Dataiku UI. This is not evidence that no environment is - needed. -- A rebuilt environment can still have failed to build. Read `code_env_rebuild` before - treating the plugin as ready: `status: "failed"` includes the dependency error even - though the plugin update itself completed. Fix the plugin's specification, then ask - the user to retry the environment rebuild in the Dataiku UI. Environment recreation - also belongs in the Dataiku UI. -- Inspect a bound environment with `list_code_envs` per `./code-environments.md`. Its - packages come from the plugin's specification and are not editable through - `update_code_env`. +- `ensure_plugin_code_env` creates the plugin's PLUGIN_MANAGED environment and binds it + once the build finishes. Never substitute `create_code_env`. `not_required` means the + plugin declares no environment; install scripts, system packages, and presets may + still need the Dataiku UI. +- `python_interpreter`, `future_id`, and `code_env_name` are mutually exclusive. + Omit `python_interpreter` to let Dataiku select an interpreter based on the plugin's + accepted versions and server availability. It only applies to a new environment. + An already-bound plugin is returned as is and refuses a different environment or interpreter. +- On `still_running`, resume with the same `plugin_id` and `future_id` on the same + instance; never start another build. Dataiku forgets a build once its result is + read, so a `future_id` only resumes a build that has not been reported finished. + Do not fetch this build's result with `get_future_status(fetch_result=true)`; + `ensure_plugin_code_env` needs that result to finish binding the environment. +- `recovery_required` means an unbound managed environment already exists and may + still be building. Resume its future while it runs; otherwise check + `code_env_names` with `list_code_envs` and pass one as `code_env_name`. +- Automation futures omit the environment name. Headless can resume them only + when it retained the build for the same caller, instance, and plugin. After a + server restart, inspect the finished environment and recover with `code_env_name`. +- `binding_unknown` means the binding request lost its connection. The environment + name and build outcome are retained. Inspect the plugin binding before retrying + with that `code_env_name`. +- `binding_changed` means someone else bound another environment during the build. + That binding is kept; ask the user which environment the plugin should use. +- Top-level `completed` means the environment is bound, not that it works. Read + `build.status` (`completed`, `failed`, or `unknown`) before treating the plugin as + ready. +- `update_plugin(rebuild_code_env=true)` rebuilds an already-bound environment; it + never creates or binds one. A rebuild keeps the interpreter and core-package set the + environment was created with, so after correcting those in `desc.json`, ask the user + to recreate the environment in the Dataiku UI. +- Inspect environments with `list_code_envs` per `./code-environments.md`. Their + packages come from the plugin and are not editable via `update_code_env`. ## Deletion @@ -98,20 +200,23 @@ plus the `operation` it describes. `unresolvable_components`. - `force=true` deletes despite usages and **breaks every blocker**, including blockers omitted from the initial refusal. Use it only on explicit instruction after showing the - user every page from `list_plugin_usages`. Deleting a plugin does not delete its code - environment. + user every page from `list_plugin_usages`. Deleting a plugin also deletes its + plugin-managed code environments. ## Out of scope -- **Plugin settings, presets, and parameter sets.** These commonly hold credentials, and +- **Installed plugin settings, presets, and parameter sets.** These commonly hold credentials, and parameter names are not a secrecy boundary either, so no tool reads or writes them, and `list_plugins` never returns plugin configuration. Configure presets in the Dataiku UI. -- Project-level plugin settings, plugin file editing, and plugin download. Development - plugin authoring happens in the Dataiku UI or in a checkout. +- Project-level plugin settings, editing files on the instance, and plugin download. + You can write plugin files and parameter-set descriptors locally using the + development guide; users enter actual preset values in the UI. ## Preferred Tools - `list_plugins` - `list_plugin_usages` +- `install_plugin` +- `ensure_plugin_code_env` - `update_plugin` - `delete_plugin` diff --git a/skills/dataiku-headless/references/agents/agent-tools.md b/skills/dataiku-headless/references/agents/agent-tools.md index b97a2243..5f8a3792 100644 --- a/skills/dataiku-headless/references/agents/agent-tools.md +++ b/skills/dataiku-headless/references/agents/agent-tools.md @@ -19,6 +19,11 @@ Agent tools are project-level Dataiku objects that agents call during execution. Additional tool types can be added through plugins or pre-configured connections. Examples include SQL Question Answering, Semantic Model Query, Google Search, Jira, Salesforce, ServiceNow, Snowflake Cortex, and Databricks Genie. Custom plugin tools use a `Custom_agent_tool__` type name. Inspect available tools rather than assuming a plugin or connection exists. +To author a reusable agent-tool plugin component from local source, read +`../plugin-development.md`, then its agent-tool component reference. Use Cobuild for +project-level setup when it supports the plugin's agent-tool type; otherwise, +configure the tool in the Dataiku UI. + ## Interpretation Notes - `additionalDescriptionForLLM` is the primary signal for tool selection. It should name the tool's purpose, appropriate inputs, and intended situations. diff --git a/skills/dataiku-headless/references/cobuild.md b/skills/dataiku-headless/references/cobuild.md index 256275c1..31e8dcda 100644 --- a/skills/dataiku-headless/references/cobuild.md +++ b/skills/dataiku-headless/references/cobuild.md @@ -5,6 +5,8 @@ description: Use Dataiku Cobuild for project-level asset creation, modification, # Cobuild +All Cobuild tools require `design`, including read-only conversations, listing, and polling. Automation cannot use Cobuild; switch to a supported instance before this workflow. + Cobuild is Dataiku's AI building agent for co-developing Dataiku projects. It can build and modify flows, recipes, models, dashboards, webapps, notebooks, wiki content, agents, scenarios, and other project assets through a retained conversation. Use this guide as the default path for project-level asset creation. This includes both broad requests such as "build me a flow for this use case" and narrow requests such as "create a filter recipe on dataset X". @@ -52,10 +54,12 @@ Do not use this guide when: - Unless the user explicitly requests otherwise, keep each turn to one coherent unit of work. For example, creating a recipe, adding descriptions to a number of objects, editing a Wiki. The intention here is to not submit large, monolithic instructions to Cobuild that cover many actions at once. - Large transformations may hit Cobuild's server-side time cap and end mid-turn. After a timed-out turn, inspect the project to see what was actually created, then re-issue instructions covering only the remaining work. +- Include the user's explicit requirements in the instruction, using their wording for names, titles, and formats, and avoid narrowing, deferring, or substituting what was asked for without checking with the user. - Prefer explicit Dataiku object names in prompts. - For creation requests, describe the intended asset, inputs, outputs, and constraints clearly. - If Cobuild needs project context, mention the relevant datasets, recipes, folders, models, dashboards, or other assets directly by name. - For existing assets, include the configuration and constraints discovered through the relevant guide. +- Prefer verifying results by reading the project directly (for example `./dashboards.md` and `./datasets.md`) over asking Cobuild to verify its own work. - When the request touches project library source, state the destination path chosen per `./project-libraries.md`. ## Preferred Tools diff --git a/skills/dataiku-headless/references/container-execution.md b/skills/dataiku-headless/references/container-execution.md index 4aa71b3e..a9dc36b0 100644 --- a/skills/dataiku-headless/references/container-execution.md +++ b/skills/dataiku-headless/references/container-execution.md @@ -5,6 +5,8 @@ description: Select where an existing recipe, ML task, WebApp backend, Knowledge # Container Execution Placement +`set_container_exec_config` requires `design`; placement changes are unavailable on Automation. + `set_container_exec_config` changes only the container execution selection of one existing object. It preserves the object's logic, code, inputs, outputs, and every other setting. This is a narrow direct-write exception and does not require Cobuild. diff --git a/skills/dataiku-headless/references/dashboards.md b/skills/dataiku-headless/references/dashboards.md index 847de47a..e0865087 100644 --- a/skills/dataiku-headless/references/dashboards.md +++ b/skills/dataiku-headless/references/dashboards.md @@ -21,6 +21,7 @@ Page filters apply to a dashboard page, not to an individual tile. A page filter 2. Use `get_dashboard_settings` to inspect a selected dashboard before asking Cobuild to change it. 3. When a dashboard uses existing insights, use `./insights.md` to inspect those insights before preparing the Cobuild request. For greenfield work, define the required insights and dashboard relationship in the request. 4. Route dashboard and underlying-insight creation, edits, and deletion through `./cobuild.md`. +5. When the request specifies a title for a chart or table, the dashboard tile title is a reliable place to set it. Confirm it in the tile's title in `get_dashboard_settings`. ## Preferred Tools @@ -30,3 +31,4 @@ Page filters apply to a dashboard page, not to an individual tile. A page filter ## Safety Rules - Inspect page-filter datasets and their affected insights before requesting a filter change. +- If a saved insight cannot be renamed through Cobuild, setting the tile title is an alternative. diff --git a/skills/dataiku-headless/references/datasets.md b/skills/dataiku-headless/references/datasets.md index a45f7bcf..d8b1b626 100644 --- a/skills/dataiku-headless/references/datasets.md +++ b/skills/dataiku-headless/references/datasets.md @@ -5,6 +5,8 @@ description: Understand and inspect Dataiku datasets, including their storage, s # Datasets +Dataset inspection, export, and builds are available on `design` and `automation`. New dataset creation, including direct uploads and Cobuild, requires `design`. + Use this guide to understand, inspect, and locally export Dataiku datasets, gather context for Cobuild, and handle the direct Uploaded Files creation exception. ## Dataset Concepts @@ -40,9 +42,19 @@ The direct-upload exception applies only to creating a new Uploaded Files datase 3. Use `get_dataset_profile` to inspect null rates, value frequencies, distributions, and numeric ranges. 4. Use `get_dataset_sample` when raw values or formatting details matter, such as date formats, delimiters, text structure, or unexpected encodings. 5. In stdio, use `export_dataset` when every row is needed for parity checks, offline inspection, or downstream local tooling. It is unavailable in Streamable HTTP because it writes a local CSV. On large datasets this can take a while to run, so prefer the lighter inspection tools unless a full local CSV is truly needed. Use `limit` to bound the export; the tool will not export more than 1,000,000 rows in one call. -6. Use `get_dataset_metrics` and `get_dataset_column_descriptions` when attached metrics or descriptions matter. +6. Use `get_dataset_metrics` and `get_dataset_column_descriptions` when attached metrics or descriptions matter. `get_dataset_metrics` recomputes the requested metrics on the current data before it reads them, which needs project write access and scans the data for record counts. See [Metric Freshness](#metric-freshness). 7. Interpret findings in context. Flag type mismatches, high null rates, outliers, duplicates, skewed values, identifier-like columns, and potential leakage or small-sample risks when they affect the user's next step. +## Metric Freshness + +Stored metric values are not live. Dataiku's built-in `records:COUNT_RECORDS` metric does not recompute on build by default, so its stored value can describe data from before the latest build. External datasets can also change without any Dataiku build. + +- Keep the default `refresh=true` when a number feeds a decision, a parity check, or a user answer. +- Check `refreshed` in the response. When it is `false`, report the value as possibly stale, quote `refresh_error`, and name every id in `stale_metric_ids`. Those values were computed before the last build started. +- A successful computation can leave stored values untouched, for example when their probes are disabled. `refreshed` is true only when the computation report includes every requested metric (or every returned metric when no ids are specified). `unrefreshed_metric_ids` identifies metrics absent from that report; treat them as possibly stale even if no build has occurred. +- `refresh=false` reads stored values only. Use it for metric history or when recomputing is too expensive, and say the values may be stale. +- An empty `stale_metric_ids` after `refresh=false` does not prove freshness. External data can change without a build. + ## Modification Routing Route all dataset modifications through `./cobuild.md`, including schema changes, column descriptions, semantic meanings, AI metadata application, managed/external dataset changes, and deletion. diff --git a/skills/dataiku-headless/references/datasets/uploaded-files-datasets.md b/skills/dataiku-headless/references/datasets/uploaded-files-datasets.md index bca1f2e9..53df6f5f 100644 --- a/skills/dataiku-headless/references/datasets/uploaded-files-datasets.md +++ b/skills/dataiku-headless/references/datasets/uploaded-files-datasets.md @@ -1,5 +1,7 @@ # Uploaded Files Datasets +`create_upload_dataset` requires `design`; Automation cannot create uploaded datasets. + Read this reference only when the user wants to bring supplied data into a project as a new Uploaded Files dataset. This reference covers the generic upload surface only. Excel ingest configuration — sheet selection, header offset, number-format behavior, identifier typing, and splitting one workbook into several datasets — is owned by [Ingest and Reshape Traps](../migrations/sources/excel/guides/build.md#ingest-and-reshape-traps). diff --git a/skills/dataiku-headless/references/insights.md b/skills/dataiku-headless/references/insights.md index ab5edd1b..6d4683e4 100644 --- a/skills/dataiku-headless/references/insights.md +++ b/skills/dataiku-headless/references/insights.md @@ -38,6 +38,7 @@ An insight tile can reference an insight by `insightId`, but the dashboard skill - WebApps for `web_app`. 4. When an insight will be used on a dashboard, use `./dashboards.md` to inspect the dashboard and its existing insight references. For greenfield work, define the required source object, insight, and dashboard relationship in the Cobuild request. 5. Route insight creation, updates, and deletion through `./cobuild.md`. +6. When the request gives a name for a chart or table insight, include it when asking Cobuild to create the insight and check it with `get_insight_settings`. If a saved insight cannot be renamed, the dashboard tile title can carry the requested title (`./dashboards.md`). ## Preferred Tools diff --git a/skills/dataiku-headless/references/jobs.md b/skills/dataiku-headless/references/jobs.md index 479dc041..d2c38b99 100644 --- a/skills/dataiku-headless/references/jobs.md +++ b/skills/dataiku-headless/references/jobs.md @@ -25,6 +25,28 @@ A wait timeout or interrupted client call ends observation, not necessarily exec 6. Use `get_job_log` when execution logs or failure text are needed. 7. Use `abort_job` to stop a job that must not continue, then confirm the terminal state before any replacement run. +## Futures + +Some instance operations, including plugin installation, updates, deletion, and +code-environment builds, return a `future_id` rather than a project `job_id`. +Use `get_future_status` on the same instance; project job tools do not track futures. + +- Poll with `fetch_result=false` to inspect `state.alive`, `state.hasResult`, and + `state.aborted` without consuming the result. A timeout ends observation, not execution. +- When `state.hasResult` is true, use `fetch_result=true` to retrieve `state.result`. + Save the result: Dataiku forgets a completed future after its result is fetched. +- Check whether the operation was aborted or stopped without a result. If it returned + a result, follow the relevant guide to check for failure: plugin actions can return + `success=false`, and environment builds report failures inside `messages`. +- For initial plugin environment setup, resume through `ensure_plugin_code_env` + with the original `plugin_id` and `future_id` instead of fetching its result here. + That tool needs the result to finish binding; follow `./administration/plugins.md`. +- Plugin future responses retain completion flags, plugin ID/version, elapsed time + (`runningTime`, in milliseconds), and phase/progress values in `progress.states`, + plus a safe failure notice. Settings, logs, and raw errors are omitted because they + may contain credentials. Inspect server logs in Dataiku when diagnosis needs those details. +- Keep supervising the existing future; do not repeat the operation to poll it. + ## Preferred Tools - `list_jobs` @@ -34,6 +56,7 @@ A wait timeout or interrupted client call ends observation, not necessarily exec - `wait_for_job` - `get_job_log` - `abort_job` +- `get_future_status` ## Safety Rules diff --git a/skills/dataiku-headless/references/llms-and-knowledge-banks.md b/skills/dataiku-headless/references/llms-and-knowledge-banks.md index b663c162..c4f008d3 100644 --- a/skills/dataiku-headless/references/llms-and-knowledge-banks.md +++ b/skills/dataiku-headless/references/llms-and-knowledge-banks.md @@ -22,7 +22,7 @@ Knowledge Banks and RAG LLMs are project objects. Reproducible GenAI flow steps 1. Use `list_llms` to discover available LLMs and `get_llm_info` to inspect a selected model's capabilities and configuration. 2. Use `list_knowledge_banks` to discover Knowledge Banks and `get_knowledge_bank_settings` to inspect a selected bank. 3. Use `search_knowledge_bank` only when validating retrieved content, diagnosing retrieval relevance, or gathering context for a retrieval change. -4. Use `list_retrieval_augmented_llms` to discover RAG LLMs and `get_retrieval_augmented_llm_settings` to inspect a selected object. +4. Use `list_rag_llms` to discover RAG LLMs and `get_rag_llm_settings` to inspect a selected object. 5. If the request only changes where a Knowledge Bank build runs, use `./container-execution.md`. This is a narrow direct-write exception and does not require Cobuild. 6. Route all other Knowledge Bank and RAG-LLM creation, edits, deletion, and builds through `./cobuild.md`. 7. Route GenAI flow-step creation or changes through `./recipes.md` and Cobuild. @@ -34,8 +34,8 @@ Knowledge Banks and RAG LLMs are project objects. Reproducible GenAI flow steps - `list_knowledge_banks` - `get_knowledge_bank_settings` - `search_knowledge_bank` -- `list_retrieval_augmented_llms` -- `get_retrieval_augmented_llm_settings` +- `list_rag_llms` +- `get_rag_llm_settings` ## Safety Rules diff --git a/skills/dataiku-headless/references/managed_folders.md b/skills/dataiku-headless/references/managed_folders.md index 13c627e1..8dbe3710 100644 --- a/skills/dataiku-headless/references/managed_folders.md +++ b/skills/dataiku-headless/references/managed_folders.md @@ -5,6 +5,8 @@ description: Understand and inspect Dataiku managed folders and, when explicitly # Managed Folders +Folder reads are available on `design` and `automation`. Creation, file upload, and Cobuild changes require `design`. + Use this guide to understand and inspect managed folders, and to handle the narrow direct exceptions for folder creation and local-file upload. ## Managed Folder Concepts diff --git a/skills/dataiku-headless/references/migrations.md b/skills/dataiku-headless/references/migrations.md index 31cea0ab..e6ba43f1 100644 --- a/skills/dataiku-headless/references/migrations.md +++ b/skills/dataiku-headless/references/migrations.md @@ -6,6 +6,8 @@ description: Translate business logic from third-party tools (e.g. Alteryx, Tabl # Migrations +Migration construction requires a `design` instance. Automation can inspect and execute existing assets but cannot build the migrated project or use Cobuild. + Translate business logic from third-party tools (e.g. Alteryx, Tableau Prep, SAS, Excel) into runnable Dataiku flows. A migration consists of four phases: plan, build, validate, and document and cleanup. The "build" and "validate" phase may loop multiple times if the "validation" encounters issues with the assets created in the "build" phase. The loaded Source Subskill may add deliverables and phase steps of its own; they are binding. diff --git a/skills/dataiku-headless/references/migrations/sources/excel/guides/validate.md b/skills/dataiku-headless/references/migrations/sources/excel/guides/validate.md index 4155bd4e..839b3e15 100644 --- a/skills/dataiku-headless/references/migrations/sources/excel/guides/validate.md +++ b/skills/dataiku-headless/references/migrations/sources/excel/guides/validate.md @@ -28,7 +28,7 @@ Choose the anchor before building the check. ## Parity Mechanism -Aggregates are gates, never verdicts. Compare fresh, exhaustive row counts and useful column aggregates against workbook-derived values before the conclusive check. `get_dataset_profile` can truncate at `max_rows`, and `get_dataset_metrics` returns last-computed values; treat either as diagnostic unless its coverage and freshness are verified. +Aggregates are gates, never verdicts. Compare fresh, exhaustive row counts and useful column aggregates against workbook-derived values before the conclusive check. `get_dataset_profile` can truncate at `max_rows`, and `get_dataset_metrics` is fresh only when its response has `refreshed: true`; treat either as diagnostic unless its coverage and freshness are verified. Default to a full local export diff. Use in-Dataiku verification instead when the output exceeds a declared local disk or comparison-memory budget, CSV cannot preserve a required distinction, or the verdict must remain in the project. Name the path and reason in the validation plan. diff --git a/skills/dataiku-headless/references/plugin-development.md b/skills/dataiku-headless/references/plugin-development.md new file mode 100644 index 00000000..0f9f8e69 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development.md @@ -0,0 +1,115 @@ +--- +name: plugin-development +description: Build or update a Dataiku plugin locally, then test, package, and install it. +--- + +# Plugin Development + +Use this guide to build or update a Dataiku plugin. First check whether a built-in +Dataiku feature or an existing [store plugin](./administration/plugins.md#store-discovery) +meets the need. For installation, updates, removal, and code +environment setup, follow [Plugins](./administration/plugins.md). + +## Before You Start + +Work in a local checkout. Headless can upload the finished package over stdio or +install or update it from a Git repository over either transport. It cannot create a +development plugin or edit its files on the instance. + +Read the repository instructions, `plugin.json`, and the code and descriptors for +the components you will change. Check dependencies, packaging commands, and tests. +Preserve IDs, role names, and configuration keys that existing usages depend on; +explain how users should migrate if you need to change them. + +For a new plugin, identify its purpose, ID, Dataiku version, and local folder. Choose +a test instance before installation. Start from a template the user supplies or the +[official component tutorials](https://developer.dataiku.com/latest/tutorials/plugins/index.html) +for that Dataiku version. + +The plugin code can use documented Dataiku APIs. To change the instance, use +Headless tools; to build project assets, use [Cobuild](./cobuild.md). If the tools +cannot perform a needed step, explain what must be done in the UI rather than +working around the gap with SDK or REST calls. + +## Choose The Component + +Read the reference for the component you are building. + +| Component | Use for | Read next | +| --- | --- | --- | +| Agent tool | An operation called by an agent | [Agent tools](./plugin-development/agent-tools.md) | +| Recipe | A Flow transformation | [Recipes](./plugin-development/recipes.md) | +| Dataset connector | External records with a schema | [Connectors](./plugin-development/connectors.md#dataset-connectors) | +| Filesystem provider | Browsing and streaming external files | [Connectors](./plugin-development/connectors.md#filesystem-providers) | +| Macro | An operation or report a user runs | [Macros](./plugin-development/macros.md) | +| Webapp | A reusable visualization or application | [Webapps](./plugin-development/webapps.md) | +| File format | Reading or writing a file structure | [File formats](./plugin-development/file-formats.md) | +| Prepare processor | A reusable Prepare step | [Prepare processors](./plugin-development/prepare-processors.md) | +| Prediction algorithm | A Visual ML estimator | [Visual ML extensions](./plugin-development/machine-learning.md#prediction-algorithms) | +| Feature preprocessor | Preparing features for training and scoring | [Visual ML extensions](./plugin-development/machine-learning.md#feature-preprocessors) | +| Custom LLM | A model provider in the LLM Mesh | [Custom LLMs](./plugin-development/llms.md) | +| Custom agent | Reusable agent logic | [Custom agents](./plugin-development/agents.md) | +| Guardrail | Checking or modifying LLM inputs and outputs | [Guardrails](./plugin-development/guardrails.md) | +| Structured-agent block | A Structured Visual Agent step | [Structured-agent blocks](./plugin-development/structured-agent-blocks.md) | +| Parameter set | Shared configuration and credentials | [Settings and checks](./plugin-development/settings-checks.md#parameter-sets) | +| Project Standards check | Evaluating project quality | [Settings and checks](./plugin-development/settings-checks.md#project-standards-checks) | + +For other types, including scenario steps and triggers, metric probes and checks, exporters, +and sample datasets, consult the target version's +[component catalog](https://doc.dataiku.com/dss/latest/plugins/reference/plugins-components.html) +and the starter code generated by Dataiku. + +## Writing The Plugin + +- Set the plugin ID, version, and display information in `plugin.json`. Define each + component's parameters in its descriptor, with clear descriptions of what it does, + what inputs it needs, and what it returns. +- Keep component entry points short and put shared logic in `python-lib/`, which + Dataiku adds to the Python path. Where practical, keep that logic independent of + Dataiku so you can test it locally. +- Keep component settings separate from plugin-wide `plugin_config`. Use configured + connections or [parameter sets](./plugin-development/settings-checks.md#parameter-sets) + for credentials. Users enter actual secrets and preset values in the UI. +- Set `mandatory` explicitly and choose defaults that make sense for the component. + Validate inputs in the code as well as the UI. Write `visibilityCondition` in + JavaScript using `model` to access parameter values. +- Use named loggers and record only the trace fields you need. Keep credentials, + whole configuration objects, request headers, and sensitive results out of logs + and traces. Remove sensitive details from errors shown to users. +- Define a plugin-managed Python environment in `code-env/python/desc.json` and + `code-env/python/spec/requirements.txt`. Check that its interpreter and packages + work with the intended Dataiku version and component. Install dependencies for + prediction algorithms and feature preprocessors in the code environment used to + train and score the model. A root + `requirements.json` only describes dependencies; it does not install them. + +See [parameters](https://doc.dataiku.com/dss/latest/plugins/reference/params.html) +and [dependencies and shared libraries](https://doc.dataiku.com/dss/latest/plugins/reference/other.html). + +## Test And Package + +1. Run the repository's documented tests, using fakes for external services and + Dataiku APIs. Check invalid inputs, empty results, failures, and whether outputs + serialize correctly. Each component reference lists any additional checks. +2. Read the packaging command before running it. For example, `git archive HEAD` + leaves out uncommitted changes. Check that the package contains your intended + changes and follow the repository's release process. +3. Inspect the files in the ZIP. Include the code, resources, and built frontend + assets the plugin needs. Leave out credentials, `.env`, local configuration, + virtual environments, caches, and unrelated test data. Prefer a checked ZIP: + directory uploads only skip `.git` and `__pycache__`. + +## Deploy And Verify + +Follow [Plugins](./administration/plugins.md) to install or update the package and +prepare its environment on the test instance. If the changes are already in a +remote repository, follow that guide's Git workflow to deploy the chosen revision. +The installed package is not an +editable development plugin. Use the UI for any required reload or restart. + +Set up the test project through Cobuild, then run existing recipes or scenarios +with the execution tools. Use the UI where needed. Test the installed component and +inspect its results, following its reference. Keep run IDs so you can follow up +through [Jobs](./jobs.md). Report the package revision, test instance, results, and +anything still untested or requiring UI setup. Installation alone does not show +that the component works. diff --git a/skills/dataiku-headless/references/plugin-development/agent-tools.md b/skills/dataiku-headless/references/plugin-development/agent-tools.md new file mode 100644 index 00000000..daf8b0ef --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/agent-tools.md @@ -0,0 +1,53 @@ +# Agent-Tool Plugin Components + +Start with [Plugin development](../plugin-development.md). The plugin defines a tool +type that users can configure as an agent tool in their projects. Tools can perform +calculations, process inputs, work with Dataiku resources, or call external services. + +## Implement The Tool + +Use `python-agent-tools//tool.json` and `tool.py`. +`tool.json` defines any settings users configure, such as processing options or +result limits. Keep these separate from the arguments the model supplies on each call. + +Implement `BaseAgentTool` from `dataiku.llm.agent_tools` following the +[custom-tool contract](https://developer.dataiku.com/latest/tutorials/plugins/custom-tools/generality/index.html): + +| Method | Purpose | +| --- | --- | +| `set_config(config, plugin_config)` | Store and validate the settings. | +| `get_descriptor(self, tool)` | Describe when to use the tool and define its JSON `inputSchema`. | +| `invoke(self, input, trace)` | Read `input["input"]`; return `output` and optional `sources` and `artifacts`. | +| `load_sample_query(self, tool)` | Return sample arguments for the test panel. | + +In the tool test panel, put the arguments inside `input`. For example, a calculation +tool might accept: + +```json +{"input": {"values": [2, 4, 6]}, "context": {}} +``` + +## User Access And Results + +- If the tool needs credentials, get them from a configured connection or preset. + Do not ask the model to supply credentials or choose the user identity. +- If the tool needs to run as the Dataiku end user, use `context.dkuCallerTicket` + with `TicketImpersonationContext`, following the + [end-user impersonation example](https://developer.dataiku.com/latest/concepts-and-examples/agents.html#impersonate-end-user-identity-in-a-custom-agent-tool). + If the ticket is required but missing, return an error. + Running as the tool runner instead must be an explicit setting. +- Put the answer in `output`, supporting evidence in `sources`, and displayable + results such as tables in `artifacts`. Follow the [sources and artifacts reference](https://developer.dataiku.com/latest/concepts-and-examples/agents.html). +- If the tool queries a service, explain what each limit controls. Limiting returned + rows or displayed tables may not reduce the work or cost of the underlying query. + +Test input validation, outputs, and failures, plus any identity settings, limits, +or service responses the tool uses. In Dataiku, check that the agent selects the tool +appropriately and displays its results, including any citations and artifacts. +Use the tool test UI when Headless cannot run the tool. + +## Example + +[ServiceNow](https://github.com/dataiku/dss-plugin-servicenow) provides external-service +examples in `python-agent-tools/`: `incident-lookup` reads incidents and `create-issue` +creates them. They share a client and use presets for authentication. diff --git a/skills/dataiku-headless/references/plugin-development/agents.md b/skills/dataiku-headless/references/plugin-development/agents.md new file mode 100644 index 00000000..dcec2fed --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/agents.md @@ -0,0 +1,11 @@ +# Custom Agent Plugin Components + +Start with [Plugin development](../plugin-development.md). For a tool that an agent +calls, see [Agent tools](./agent-tools.md). + +Use `python-agents//agent.json` and `agent.py`, following the +[custom-agent tutorial](https://developer.dataiku.com/latest/tutorials/plugins/agent/generality/index.html). +Agents use the completion interface rather than a tool's `invoke` method. If the agent +uses an LLM, dataset, or Knowledge Bank, let users select it in the agent's settings. +Preserve the conversation history the agent needs and pass traces through its calls. +Test supported response modes and tool failures. diff --git a/skills/dataiku-headless/references/plugin-development/connectors.md b/skills/dataiku-headless/references/plugin-development/connectors.md new file mode 100644 index 00000000..116a39bd --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/connectors.md @@ -0,0 +1,41 @@ +# Dataset Connectors And Filesystem Providers + +Start with [Plugin development](../plugin-development.md). Choose a dataset connector +for records with a schema, or a filesystem provider to browse and read files. + +## Dataset Connectors + +Use `python-connectors//connector.json` and `connector.py`, following +[Connector and CustomDatasetWriter](https://developer.dataiku.com/latest/api-reference/python/plugin-components/custom_datasets.html). + +- Derive readers from `dataiku.connector.Connector`. `get_read_schema()` returns a + `columns` schema or `None` for inference; `generate_rows(...)` yields dictionaries + keyed by column name. +- Make sure the returned types and schema match the descriptor. Handle pagination, + partitions, and preview limits; avoid fetching every page for a small preview. +- Add a `CustomDatasetWriter` if the connector needs to write data. Define how it + appends or overwrites, sends batches, finishes, and handles a failure partway through. +- Put service calls in shared helpers. Retry only when repeating the operation is + safe, and close clients and streams even when a call fails. + +Test pagination, type conversion, preview limits, and supported writes. In Dataiku, +check both the dataset preview and a full read or build. +[API Connect](https://github.com/dataiku/dss-plugin-api-connect) illustrates service +connectors, pagination, and authentication helpers. + +## Filesystem Providers + +Use `python-fs-providers//fs-provider.json` and `fs-provider.py`, following +[FSProvider](https://developer.dataiku.com/latest/api-reference/python/plugin-components/custom_fsproviders.html) +and the [descriptor reference](https://doc.dataiku.com/dss/latest/plugins/reference/fsproviders.html). + +- Keep paths relative to the configured root. Handle path formats and missing objects + consistently when listing files, reading metadata, enumerating, and streaming. +- Distinguish files from directories and return accurate sizes and modification times. + Stream large files and close resources as the API requires. +- Advertise only operations the provider implements. Test writes and deletion on + disposable files, including attempts to leave the configured root and partial failures. + +Test nested paths, empty directories, pagination, streaming, and cleanup. Verify +browsing and actual reads in Dataiku. +[OneDrive](https://github.com/dataiku/dss-plugin-onedrive) provides provider and shared-client examples. diff --git a/skills/dataiku-headless/references/plugin-development/file-formats.md b/skills/dataiku-headless/references/plugin-development/file-formats.md new file mode 100644 index 00000000..a09a0359 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/file-formats.md @@ -0,0 +1,14 @@ +# File Format Plugin Components + +Start with [Plugin development](../plugin-development.md). + +Use `python-formats//format.json` and `format.py` to read or write a +file format regardless of which connector supplies the file. Follow the [format reference](https://doc.dataiku.com/dss/latest/plugins/reference/file-format.html) +and [Formatter APIs](https://developer.dataiku.com/latest/api-reference/python/plugin-components/custom_formats.html). + +Make schema discovery, row reading, and end-of-file handling agree. Add an output +formatter if export is needed. Use small test files to check schemas, encodings, +nulls, empty or truncated input, and round-trips where supported. In Dataiku, verify +format selection, preview, and a full import. +[SAS Format](https://github.com/dataiku/dss-plugin-sas-format) illustrates parsing +helpers and fixture-based tests. diff --git a/skills/dataiku-headless/references/plugin-development/guardrails.md b/skills/dataiku-headless/references/plugin-development/guardrails.md new file mode 100644 index 00000000..2110b9f9 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/guardrails.md @@ -0,0 +1,11 @@ +# Guardrail Plugin Components + +Start with [Plugin development](../plugin-development.md). + +Use `python-guardrails//guardrail.json` and `guardrail.py`, following +[the guardrail tutorial](https://developer.dataiku.com/latest/tutorials/plugins/guardrail/generality/index.html). +Preserve the surrounding request or response structure. Specify whether the guardrail +checks inputs, outputs, or both; whether it blocks, audits, or rewrites; and what +happens when its checking service is unavailable. +Test each phase and outcome through an actual LLM call. +[TypeSafe AI](https://github.com/dataiku/dss-plugin-typesafe-ai) illustrates audit versus block modes. diff --git a/skills/dataiku-headless/references/plugin-development/llms.md b/skills/dataiku-headless/references/plugin-development/llms.md new file mode 100644 index 00000000..57a141de --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/llms.md @@ -0,0 +1,34 @@ +# Custom LLM Plugin Components + +Start with [Plugin development](../plugin-development.md). A custom LLM connection +can provide text completion, image-aware completion, embeddings, or reranking, +depending on its implementation and the provider. + +Create `python-llms//llm.json` and `llm.py` locally, following the +[custom LLM tutorial](https://developer.dataiku.com/latest/tutorials/plugins/llm-connection/generality/index.html). +Use the interface for the operation you need: + +- For completion, implement `BaseLLM` from `dataiku.llm.python`. Translate messages, + settings, tool calls, errors, and usage metadata between Dataiku and the provider. + If you support streaming, return the expected chunks and final metadata. +- For image inputs, preserve each message's text and image parts when adapting the + request. Handle the image representations the provider accepts and reject + unsupported inputs clearly. Image understanding and image generation are separate + capabilities; implementing one does not provide the other. +- For embeddings, implement `BaseEmbeddingModel` and return the vector and supported + usage metadata. Keep embedding requests separate from chat requests. Test vector + dimensions, input limits, and any distinction between document and query embeddings. + +Advertise only capabilities you implement. Test each supported operation in Dataiku, +including malformed inputs, provider failures, streaming where supported, and whether +embeddings work with the intended retrieval workflow. + +## Examples + +- [TypeSafe AI](https://github.com/dataiku/dss-plugin-typesafe-ai): + `python-llms/jev-reranker` implements `BaseRerankingModel`, scoring documents for + relevance and returning their original indices, relevance scores, and usage metadata. +- The [custom LLM tutorial](https://developer.dataiku.com/latest/tutorials/plugins/llm-connection/generality/index.html) + shows how to translate image inputs into a provider request. +- The [LLM Mesh examples](https://developer.dataiku.com/latest/concepts-and-examples/llm-mesh.html) + show how to call embedding models through Dataiku when validating the connection. diff --git a/skills/dataiku-headless/references/plugin-development/machine-learning.md b/skills/dataiku-headless/references/plugin-development/machine-learning.md new file mode 100644 index 00000000..97be2ee5 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/machine-learning.md @@ -0,0 +1,31 @@ +# Visual ML Plugin Extensions + +Start with [Plugin development](../plugin-development.md). Check dependencies in the +environments used for training and scoring, along with the deployment options +supported by your Dataiku version. + +## Prediction Algorithms + +Use generated `algo.json` and `algo.py`, following the +[prediction-algorithm tutorial](https://developer.dataiku.com/latest/tutorials/plugins/prediction-algorithm/ml-algo/index.html). +The descriptor defines prediction types, hyperparameters, and grid search settings. For the +scikit-learn-compatible interface, derive from `BaseCustomPredictionAlgorithm` and +implement `get_clf()` with a matching estimator. + +Test training, predictions, probabilities where supported, parameter conversion, +and saving and loading the estimator. +In Dataiku, train, inspect metrics, and score with the saved model before claiming +support for a deployment or export mode. + +## Feature Preprocessors + +Use `python-preprocessors//preprocessor.json` and `preprocessor.py`, +following the [preprocessor reference](https://doc.dataiku.com/dss/latest/plugins/reference/ml-preprocessors.html). +Declare supported feature types and implement `CustomPreprocessor`. Learn statistics +in `fit`; reuse them in `transform` during scoring. Keep names, dimensionality, and +row alignment stable. + +Test unseen categories, nulls, constant inputs, and whether saved models retain the +preprocessor's fitted state. These +components run in the model's process, not a dedicated plugin environment. Verify +training and subsequent scoring, and consult the reference's export limitations. diff --git a/skills/dataiku-headless/references/plugin-development/macros.md b/skills/dataiku-headless/references/plugin-development/macros.md new file mode 100644 index 00000000..6df1816a --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/macros.md @@ -0,0 +1,22 @@ +# Macro Plugin Components + +Start with [Plugin development](../plugin-development.md). Use a macro for an +operation or report that a user runs. Make its effects clear in the label and settings. + +Use `python-runnables//runnable.json` and `runnable.py`, following +[the macro reference](https://doc.dataiku.com/dss/latest/plugins/reference/macros.html). +Derive from `dataiku.runnables.Runnable`. The constructor receives `project_key`, +`config`, and `plugin_config`; put the operation in `run(progress_callback)`. + +- Set `permissions` for required project access and `requiresGlobalAdmin` only when + necessary. Choose `impersonate` deliberately for the instance's user-isolation setup. +- Use `macroRoles` to add the macro to an object's menu and fill in its settings. +- Match the return value to `resultType`: return a string for HTML or URL, or a + `dataiku.runnables.ResultTable` for RESULT_TABLE. Escape values inserted into HTML + and scope styles to the result. +- For long work, use `get_progress_target()` and `progress_callback`. Report what + completed before a failure so callers know whether it is safe to retry. + +Test how results serialize, progress reporting, and failures partway through. Run +the installed macro in the UI with the intended user permissions and check its effects; +Headless has no macro execution tool. diff --git a/skills/dataiku-headless/references/plugin-development/prepare-processors.md b/skills/dataiku-headless/references/plugin-development/prepare-processors.md new file mode 100644 index 00000000..91cbca68 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/prepare-processors.md @@ -0,0 +1,19 @@ +# Prepare Processor Plugin Components + +Start with [Plugin development](../plugin-development.md). + +Create `jython-processors//processor.json` and `processor.py` locally, +using [the processor tutorial](https://developer.dataiku.com/latest/tutorials/plugins/processor/generality/index.html) +for the descriptor and `process(row)` interface. Read configured values from `params`. +Choose CELL, ROW, or ROWS mode to match what the function returns. + +If the processor needs the plugin's Python code environment, set `useKernel: true` +in `processor.json`, as described in the +[processor reference](https://doc.dataiku.com/dss/latest/plugins/reference/preparation.html). +Check runtime and library support for the target Dataiku version. Avoid repeating +expensive setup for every row. + +Test output types, nulls, and changes to the number of rows. Compare the Prepare +preview with the schema and data produced by a build. +[Holidays](https://github.com/dataiku/dss-plugin-holidays) is a compact example; check +its execution engine before reusing code. diff --git a/skills/dataiku-headless/references/plugin-development/recipes.md b/skills/dataiku-headless/references/plugin-development/recipes.md new file mode 100644 index 00000000..6aee9eba --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/recipes.md @@ -0,0 +1,41 @@ +# Recipe Plugin Components + +Start with [Plugin development](../plugin-development.md). Use +`custom-recipes//recipe.json` and the language entry point, following +[the recipe reference](https://doc.dataiku.com/dss/latest/plugins/reference/recipes.html). + +## Roles And Schemas + +- Make `kind`, accepted object types, role `arity`, and `required` match the code. + Get inputs and outputs with `get_input_names_for_role` and + `get_output_names_for_role`, handling optional or multiple objects as declared. +- A recipe `COLUMN` parameter references an input role through `columnRole`. +- Define the output schema and validate configured columns. Process data in chunks + or streams when loading the full input would use too much memory. + +## Cobuild Integration + +Plugin recipe support starts with Dataiku **14.7.4 and 15.0.1**. Opt in with this +top-level `recipe.json` property: + +```json +"cobuild": {"supported": true} +``` + +Cobuild infers configuration from the descriptor, not the code. Declare role and +parameter constraints accurately, including conditional visibility: a parameter is +required when mandatory and visible. Prefer presets for authentication. + +Add `custom-recipes//Cobuild.md` to explain parameter relationships, +units, or output behavior that the descriptor cannot express. Cobuild cannot retrieve +dynamic choices from `getChoicesFromPython`; use values supplied by the user or +explained in the component guidance, or ask the user to select them in the UI. +Reload after descriptor changes. See [integration details](https://doc.dataiku.com/dss/latest/plugins/reference/recipes.html#make-a-plugin-recipe-available-to-cobuild). + +## Verification And Example + +Test transformation values and schemas. In Dataiku, confirm Cobuild can configure the +intended roles and parameters. Execute the recipe and inspect its dataset or folder outputs. +[NLP Preparation](https://github.com/dataiku/dss-plugin-nlp-preparation) illustrates +short entry points, shared processing code, chunked processing, and separate unit +and integration tests. diff --git a/skills/dataiku-headless/references/plugin-development/settings-checks.md b/skills/dataiku-headless/references/plugin-development/settings-checks.md new file mode 100644 index 00000000..30563c96 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/settings-checks.md @@ -0,0 +1,28 @@ +# Parameter Sets And Project Standards Checks + +Start with [Plugin development](../plugin-development.md). + +## Parameter Sets + +Use `parameter-sets//parameter-set.json`, following the +[parameter-set tutorial](https://developer.dataiku.com/latest/tutorials/plugins/parameter-sets/generality/index.html). +A parameter set defines the fields; a preset holds their values. A component refers +to it through a `PRESET` parameter and `parameterSetId`. Choose where users can +configure presets, and use `CREDENTIAL_REQUEST` for supported per-user credentials. +Read the resolved values from the nested configuration, not from a preset's display name. + +Test references and parsing with made-up values, then run a component with a preset +configured in the UI. Headless does not read or write installed preset values. +[API Connect](https://github.com/dataiku/dss-plugin-api-connect) has authentication examples. + +## Project Standards Checks + +Use `python-project-standards-check-specs//` with +`project_standards_check_spec.json` and `.py`, following the +[Project Standards tutorial](https://developer.dataiku.com/latest/tutorials/plugins/project-standards/flow-zone-check.html). +Distinguish a passing check, a quality failure, an inapplicable check, and an error +running the check. Explain the finding, affected objects, and how to fix it. A check +should inspect the project; repairs are a separate operation. + +Test projects that pass, fail, and fall outside the check's scope. Register the +installed check and its scope in the Project Standards UI, then run it on a test project. diff --git a/skills/dataiku-headless/references/plugin-development/structured-agent-blocks.md b/skills/dataiku-headless/references/plugin-development/structured-agent-blocks.md new file mode 100644 index 00000000..2b397888 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/structured-agent-blocks.md @@ -0,0 +1,11 @@ +# Structured-Agent Block Plugin Components + +Start with [Plugin development](../plugin-development.md). + +Use `python-structured-agent-blocks//block.json`. Its `pyClazzName` +names the implementation class, typically in `python-lib/`; the code does not +have to sit beside the descriptor. Follow the [block tutorial](https://developer.dataiku.com/latest/tutorials/plugins/structured-agent/generality/index.html). +Define which state keys the block reads and writes, where execution goes next, and +what the user sees. Test those behaviors, including missing state, in a small +Structured Visual Agent. +[TypeSafe AI](https://github.com/dataiku/dss-plugin-typesafe-ai) shows how the descriptor points to its implementation class. diff --git a/skills/dataiku-headless/references/plugin-development/webapps.md b/skills/dataiku-headless/references/plugin-development/webapps.md new file mode 100644 index 00000000..0a2ed8a0 --- /dev/null +++ b/skills/dataiku-headless/references/plugin-development/webapps.md @@ -0,0 +1,23 @@ +# Webapp Plugin Components + +Start with [Plugin development](../plugin-development.md). Users configure a separate +webapp in each project that uses the plugin component. + +Follow the [component reference](https://doc.dataiku.com/dss/latest/plugins/reference/webapps.html) +and [conversion tutorial](https://developer.dataiku.com/latest/tutorials/plugins/webapps/generality/index.html). +Convert a working webapp in the UI to get starter code for its type. Check the +generated files: webapp types can use different backends and filenames. + +- Standard components use `webapps//webapp.json` and type-specific files. +- `DATASET_COLUMN` references a dataset setting through `datasetParamName`; recipe + `COLUMN` parameters instead use an input role's `columnRole`. +- Read settings through the type's API, such as `dataiku.getWebAppConfig()` in standard + JavaScript or `get_webapp_config()` in Python. Keep credentials on the backend. +- Include the current built frontend assets. Check that resource URLs, backend + routes, and settings work in Dataiku as well as on a local development server. +- Keep styles and JavaScript scoped to the app, and check access to backend routes. + +In Dataiku, check rendering, configured datasets and columns, asset loading, backend +startup, and what users see when data is empty or a request fails. +[Flow Charts](https://github.com/dataiku/dss-plugin-flow-charts) has small frontend-only +examples; use current starters rather than adopting its older frontend dependencies. diff --git a/skills/dataiku-headless/references/project-libraries.md b/skills/dataiku-headless/references/project-libraries.md index abc80b22..c6db4aef 100644 --- a/skills/dataiku-headless/references/project-libraries.md +++ b/skills/dataiku-headless/references/project-libraries.md @@ -5,6 +5,8 @@ description: Understand and inspect a Dataiku project's library tree and, when e # Project Libraries +Library reads, search, and validation are available on `design` and `automation`. Direct file writes and Cobuild changes require `design`. + Use this guide to understand and inspect the project library, and to handle the direct local-source-file write exception. ## Project Library Concepts diff --git a/skills/dataiku-headless/references/projects.md b/skills/dataiku-headless/references/projects.md index 5038b529..a1c59ef2 100644 --- a/skills/dataiku-headless/references/projects.md +++ b/skills/dataiku-headless/references/projects.md @@ -5,6 +5,8 @@ description: Understand and inspect Dataiku projects, their metadata, variables, # Projects +Project creation and variable writes require `design`. Project and Flow reads are also available on `automation`; Cobuild is not. + Use this guide to understand and inspect Dataiku projects, update project variables, and gather grounded context for Cobuild. ## Project Concepts diff --git a/skills/dataiku-headless/references/projects/settings.md b/skills/dataiku-headless/references/projects/settings.md index 27224bfc..eb2555e7 100644 --- a/skills/dataiku-headless/references/projects/settings.md +++ b/skills/dataiku-headless/references/projects/settings.md @@ -5,6 +5,8 @@ description: Inspect and update Dataiku project settings. Use for Flow display a # Project Settings +`update_project_settings` requires `design`. Automation can inspect settings but cannot update them. + Project settings are a narrow direct-write exception for configuration that Cobuild does not manage. `get_project_settings` returns the editable `settings` object. `update_project_settings` accepts only the fields documented below and rejects other settings. It uses JSON Merge Patch behavior: nested objects merge, scalar values replace, and `null` removes a field. diff --git a/skills/dataiku-headless/references/recipes.md b/skills/dataiku-headless/references/recipes.md index 6a56334f..be2bbc6a 100644 --- a/skills/dataiku-headless/references/recipes.md +++ b/skills/dataiku-headless/references/recipes.md @@ -62,6 +62,7 @@ A recipe's outputs must fit the surrounding Flow. Inspect input schemas and exis - Project-library code dependencies: `./project-libraries.md` - Active or uncertain execution: `./jobs.md` - Container execution placement for one object: `./container-execution.md` +- Creating a reusable plugin recipe component from local source: `./plugin-development.md` ## Preferred Tools diff --git a/skills/dataiku-headless/references/semantic-models.md b/skills/dataiku-headless/references/semantic-models.md index 8bff523b..337e197d 100644 --- a/skills/dataiku-headless/references/semantic-models.md +++ b/skills/dataiku-headless/references/semantic-models.md @@ -34,7 +34,7 @@ Ground entities and attributes in inspected dataset schemas. Validate entity gra ## Workflow 1. Use `list_semantic_models` to discover models, versions, and the active version. -2. Use `get_semantic_model_version_settings` to inspect a selected version's business definition. +2. Use `get_semantic_model_version_config` to inspect a selected version's business definition. 3. Inspect source datasets and schemas before requesting entity, attribute, metric, filter, or relationship changes. 4. Decide whether a request should change the active version or create a version for review first. 5. Route semantic-model creation, versioning, activation, indexing, and deletion through `./cobuild.md`. @@ -48,7 +48,7 @@ Ground entities and attributes in inspected dataset schemas. Validate entity gra ## Preferred Tools - `list_semantic_models` -- `get_semantic_model_version_settings` +- `get_semantic_model_version_config` ## Safety Rules diff --git a/tests/conftest.py b/tests/conftest.py index 59d47827..3df6f926 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,7 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID @pytest.fixture @@ -40,6 +40,9 @@ def localhost_certificate(tmp_path): x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False ) .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False + ) .sign(key, hashes.SHA256()) ) pem = certificate.public_bytes(serialization.Encoding.PEM).decode("ascii") diff --git a/tests/test_dataset_metrics.py b/tests/test_dataset_metrics.py new file mode 100644 index 00000000..88741481 --- /dev/null +++ b/tests/test_dataset_metrics.py @@ -0,0 +1,203 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for dataset metric freshness. + +The fake mirrors DSS defaults: a build records ``basic:*`` metrics, but the +built-in ``records:COUNT_RECORDS`` probe does not compute on build, so its stored +value keeps describing the data from before the build until it is recomputed. +""" + +import asyncio +import json + +import pytest + +from dataiku_mcp.tools import datasets +from dataiku_mcp.tools.utils.metrics import stale_metric_ids +from tests.utils.fakes import FakeContext + +OLD_COUNT_TIME = 1_000 +BUILD_START = 2_000 +BUILD_METRIC_TIME = 2_100 +REFRESH_TIME = 3_000 + + +def _metric(metric_id: str, value: str, computed: int) -> dict: + return { + "metric": {"id": metric_id}, + "lastValues": [ + { + "computed": computed, + "partition": "NP", + "dataType": "BIGINT", + "value": value, + } + ], + } + + +class _Raw: + def __init__(self, raw: dict): + self.raw = raw + + def get_raw(self) -> dict: + return self.raw + + +class _StaleCountDataset: + """Holds 250 rows now; its stored record count still says 100.""" + + def __init__(self, *, compute_error=None, engine_error=None, count_enabled=True): + self.live_count = "250" + self.stored = { + "records:COUNT_RECORDS": _metric( + "records:COUNT_RECORDS", "100", OLD_COUNT_TIME + ), + "basic:SIZE": _metric("basic:SIZE", "4096", BUILD_METRIC_TIME), + } + self.compute_error = compute_error + self.engine_error = engine_error + self.count_enabled = count_enabled + self.compute_calls: list = [] + + def compute_metrics(self, partition="", metric_ids=None, probes=None): + self.compute_calls.append(metric_ids) + if self.compute_error: + raise self.compute_error + runs = [{"engine": "SQL"}] + computed = [] + if self.engine_error: + runs[0]["error"] = {"message": self.engine_error} + else: + enabled_ids = ["basic:SIZE"] + if self.count_enabled: + enabled_ids.append("records:COUNT_RECORDS") + for metric_id in enabled_ids if metric_ids is None else metric_ids: + if metric_id not in self.stored: + continue + value = ( + self.live_count if metric_id == "records:COUNT_RECORDS" else "4096" + ) + self.stored[metric_id] = _metric(metric_id, value, REFRESH_TIME) + computed.append({"metricId": metric_id, "value": value}) + return {"hasResult": True, "result": {"computed": computed, "runs": runs}} + + def get_last_metric_values(self, partition=""): + return _Raw({"metrics": list(self.stored.values())}) + + def get_info(self): + return _Raw({"lastBuild": {"buildStartTime": BUILD_START}}) + + +class _Client: + def __init__(self, dataset): + self.dataset = dataset + + def get_project(self, project_key): + assert project_key == "PROJECT" + return self + + def get_dataset(self, dataset_name): + assert dataset_name == "orders" + return self.dataset + + +def _get_metrics(monkeypatch, dataset, **kwargs) -> dict: + monkeypatch.setattr(datasets, "get_dss_client", lambda: _Client(dataset)) + return json.loads( + asyncio.run( + datasets.get_dataset_metrics("PROJECT", "orders", FakeContext(), **kwargs) + ) + ) + + +def _value(result: dict, metric_id: str) -> str: + for metric in result["metrics"]: + if metric["metric"]["id"] == metric_id: + return metric["lastValues"][0]["value"] + raise AssertionError(f"{metric_id} not returned") + + +def test_default_recomputes_instead_of_returning_the_stored_count(monkeypatch): + dataset = _StaleCountDataset() + + result = _get_metrics(monkeypatch, dataset) + + assert dataset.compute_calls == [None] + assert result["refreshed"] is True + assert _value(result, "records:COUNT_RECORDS") == "250" + assert "stale_metric_ids" not in result + + +def test_requested_ids_are_the_ones_recomputed(monkeypatch): + dataset = _StaleCountDataset() + + result = _get_metrics(monkeypatch, dataset, metric_ids='["records:COUNT_RECORDS"]') + + assert dataset.compute_calls == [["records:COUNT_RECORDS"]] + assert [m["metric"]["id"] for m in result["metrics"]] == ["records:COUNT_RECORDS"] + assert _value(result, "records:COUNT_RECORDS") == "250" + + +def test_disabled_probe_does_not_make_its_stored_count_fresh(monkeypatch): + dataset = _StaleCountDataset(count_enabled=False) + + result = _get_metrics(monkeypatch, dataset) + + assert dataset.compute_calls == [None] + assert result["refreshed"] is False + assert result["unrefreshed_metric_ids"] == ["records:COUNT_RECORDS"] + assert "refresh_error" in result + assert _value(result, "records:COUNT_RECORDS") == "100" + assert result["stale_metric_ids"] == ["records:COUNT_RECORDS"] + + +def test_stored_read_flags_values_older_than_the_last_build(monkeypatch): + dataset = _StaleCountDataset() + + result = _get_metrics(monkeypatch, dataset, refresh=False) + + assert dataset.compute_calls == [] + assert result["refreshed"] is False + assert _value(result, "records:COUNT_RECORDS") == "100" + assert result["stale_metric_ids"] == ["records:COUNT_RECORDS"] + + +def test_failed_recompute_falls_back_to_flagged_stored_values(monkeypatch): + dataset = _StaleCountDataset(compute_error=RuntimeError("Action forbidden")) + + result = _get_metrics(monkeypatch, dataset) + + assert result["refreshed"] is False + assert result["refresh_error"] == "Action forbidden" + assert _value(result, "records:COUNT_RECORDS") == "100" + assert result["stale_metric_ids"] == ["records:COUNT_RECORDS"] + + +def test_engine_failure_inside_the_report_is_not_treated_as_fresh(monkeypatch): + dataset = _StaleCountDataset(engine_error="Connection refused") + + result = _get_metrics(monkeypatch, dataset) + + assert result["refreshed"] is False + assert result["refresh_error"] == "SQL: Connection refused" + assert result["stale_metric_ids"] == ["records:COUNT_RECORDS"] + + +@pytest.mark.parametrize("last_build_start", [None, 0]) +def test_never_built_datasets_have_no_staleness_reference(last_build_start): + metrics = [_metric("records:COUNT_RECORDS", "100", OLD_COUNT_TIME)] + + assert stale_metric_ids(metrics, last_build_start) == [] diff --git a/tests/test_execution_tools.py b/tests/test_execution_tools.py index d9a73726..afdb90a0 100644 --- a/tests/test_execution_tools.py +++ b/tests/test_execution_tools.py @@ -813,7 +813,7 @@ def test_run_scenario_poll_failure_after_run_materializes_keeps_run_id(): assert res["error_type"] == "ConnectionError" -def test_run_scenario_poll_failure_is_structured_over_fastmcp_transport(): +def test_run_scenario_poll_failure_is_structured_over_fastmcp_transport(monkeypatch): """Call through the FastMCP server, not the raw function. A raised exception would reach the client as unstructured (maskable) error @@ -824,6 +824,22 @@ def test_run_scenario_poll_failure_is_structured_over_fastmcp_transport(): import dataiku_mcp + from dataiku_mcp.config import stdio + from dataiku_mcp.config.models import DSSInstance + + monkeypatch.setattr( + stdio, + "_current_instance", + DSSInstance( + name="automation", + url="https://example.com", + instance_type="automation", + api_key="test-key", + source="config", + no_check_certificate=False, + ), + ) + trigger_fire = MagicMock() trigger_fire.run_id = "TRIG-TRANSPORT" trigger_fire.get_scenario_run.side_effect = ConnectionError("poll dropped") diff --git a/tests/test_http_middleware.py b/tests/test_http_middleware.py index 4a714dc7..35efafcb 100644 --- a/tests/test_http_middleware.py +++ b/tests/test_http_middleware.py @@ -21,6 +21,12 @@ import dataiku_mcp.server as server from dataiku_mcp.config import request +from dataiku_mcp.instance_policy import ( + INSTANCE_MANAGEMENT_POLICY, + PROJECT_READ_POLICY, + PROJECT_EDIT_POLICY, +) +from fastmcp.exceptions import NotFoundError def _call_middleware( @@ -30,6 +36,9 @@ def _call_middleware( tool_tags: set[str] | None = None, tool_resolves: bool = True, include_fastmcp_context: bool = True, + instance_type: str = "design", + tool_meta: dict | None = None, + expected_error: tuple[type[Exception], str] | None = None, ): events = [] access_token = SimpleNamespace( @@ -77,6 +86,12 @@ def _call_middleware( lambda token: events.append(("reset_dss", token)), ) + monkeypatch.setattr( + request, + "get_pinned_instance", + lambda: SimpleNamespace(instance_type=instance_type), + ) + async def call_next(_context): events.append(("tool",)) return "result" @@ -86,16 +101,29 @@ async def get_tool(self, name): assert name == tool_name if not tool_resolves: return None - return SimpleNamespace(tags=tool_tags or set()) + return SimpleNamespace( + tags=tool_tags or set(), + meta=tool_meta + or ( + INSTANCE_MANAGEMENT_POLICY + if tool_name in {"switch_instance", "get_current_instance"} + else PROJECT_READ_POLICY + ), + ) fastmcp_context = None if include_fastmcp_context: fastmcp_context = SimpleNamespace(fastmcp=FakeFastMCP()) + monkeypatch.setattr(server.mcp, "get_tool", FakeFastMCP().get_tool) context = SimpleNamespace( message=SimpleNamespace(name=tool_name), fastmcp_context=fastmcp_context, ) - assert asyncio.run(middleware.on_call_tool(context, call_next)) == "result" + if expected_error: + with pytest.raises(expected_error[0], match=expected_error[1]): + asyncio.run(middleware.on_call_tool(context, call_next)) + else: + assert asyncio.run(middleware.on_call_tool(context, call_next)) == "result" return events @@ -191,21 +219,58 @@ def test_http_current_instance_exchanges_a_dss_token(monkeypatch): assert ("set_dss", "dss-token") in events -@pytest.mark.parametrize( - ("tool_resolves", "include_fastmcp_context"), - [(False, True), (True, False)], -) -def test_http_unknown_tool_policy_requires_dss_token_exchange( - monkeypatch, tool_resolves, include_fastmcp_context +@pytest.mark.parametrize("include_fastmcp_context", [True, False]) +def test_http_unknown_tool_fails_without_token_exchange( + monkeypatch, include_fastmcp_context ): events = _call_middleware( monkeypatch, "unknown_tool", - tool_resolves=tool_resolves, + tool_resolves=False, include_fastmcp_context=include_fastmcp_context, + expected_error=(NotFoundError, "Unknown tool"), ) + assert ("exchange", "mcp-token") not in events + assert ("tool",) not in events + assert events[-2:] == [ + ("reset_instance", "instance"), + ("reset_identity", "identity"), + ] - assert ("exchange", "mcp-token") in events + +@pytest.mark.parametrize("instance_type", ["govern", "deployer", "agent-management"]) +def test_http_unsupported_current_instance_skips_exchange(monkeypatch, instance_type): + events = _call_middleware( + monkeypatch, "get_current_instance", instance_type=instance_type + ) + assert ("exchange", "mcp-token") not in events + assert ("tool",) in events + + +@pytest.mark.parametrize( + "tool_name,tool_tags", + [ + ("set_project_variables", set()), + ("list_cobuild_conversations", {server.DSS_INDEPENDENT_TOOL_TAG}), + ], +) +def test_http_policy_rejection_precedes_exchange_and_handler( + monkeypatch, tool_name, tool_tags +): + events = _call_middleware( + monkeypatch, + tool_name, + instance_type="automation", + tool_tags=tool_tags, + tool_meta=PROJECT_EDIT_POLICY, + expected_error=(ValueError, "automation.*Allowed types.*switch_instance"), + ) + assert ("exchange", "mcp-token") not in events + assert ("tool",) not in events + assert events[-2:] == [ + ("reset_instance", "instance"), + ("reset_identity", "identity"), + ] def test_http_identity_is_reset_when_instance_pinning_fails(monkeypatch): diff --git a/tests/test_http_plugin_renderer.py b/tests/test_http_plugin_renderer.py index d3aa61a2..ca84c784 100644 --- a/tests/test_http_plugin_renderer.py +++ b/tests/test_http_plugin_renderer.py @@ -41,29 +41,49 @@ def _json(path: Path) -> dict: def test_renders_http_plugin_with_http_only_setup_skill(tmp_path): output = tmp_path / "dataiku-headless-http" endpoint = "https://mcp.customer.example/mcp" - source_mcp = (ROOT / ".mcp.json").read_text(encoding="utf-8") source_codex = _json(ROOT / ".codex-plugin" / "plugin.json") + source_mcp_path = ROOT / source_codex["mcpServers"] + source_mcp = source_mcp_path.read_text(encoding="utf-8") source_claude = _json(ROOT / ".claude-plugin" / "plugin.json") + github_paths = [ + ROOT / ".github/plugin" / name for name in ("plugin.json", "marketplace.json") + ] + source_github = {path: path.read_text() for path in github_paths} result = _run("--url", endpoint, "--output", str(output)) + github = _json(output / ".github/plugin/plugin.json") + assert github["mcpServers"] == {"dku": {"type": "http", "url": endpoint}} + assert github["name"] == "headless-http" + original = _json(github_paths[0]) + assert {k: v for k, v in github.items() if k not in ("name", "mcpServers")} == { + k: v for k, v in original.items() if k not in ("name", "mcpServers") + } + marketplace = _json(output / ".github/plugin/marketplace.json") + assert marketplace["plugins"][0]["name"] == github["name"] + assert marketplace["plugins"][0]["source"] == "./" + assert marketplace["renames"] == {"dataiku-headless-http": "headless-http"} + assert not (output / ".github/workflows").exists() + assert all(path.read_text() == source for path, source in source_github.items()) assert result.returncode == 0, result.stderr - assert _json(output / ".mcp.json")["mcpServers"]["dataiku"] == { - "type": "http", - "url": endpoint, + rendered_codex = _json(output / ".codex-plugin" / "plugin.json") + assert _json(output / rendered_codex["mcpServers"])["mcpServers"] == { + "dku": {"type": "http", "url": endpoint} } - assert _json(output / ".claude-plugin" / "plugin.json")["mcpServers"][ - "dataiku" - ] == {"type": "http", "url": endpoint} - assert _json(output / ".codex-plugin" / "plugin.json")["name"] == ( - "dataiku-headless-http" - ) + assert not (output / ".mcp.json").exists() + assert _json(output / ".claude-plugin" / "plugin.json")["mcpServers"] == { + "dku": {"type": "http", "url": endpoint} + } + assert _json(output / ".codex-plugin" / "plugin.json")["name"] == "headless-http" assert ( _json(output / ".claude-plugin" / "marketplace.json")["plugins"][0]["name"] - == "dataiku-headless-http" + == "headless-http" ) - rendered_codex = _json(output / ".codex-plugin" / "plugin.json") rendered_claude = _json(output / ".claude-plugin" / "plugin.json") + assert rendered_claude["name"] == "headless-http" + marketplace = _json(output / ".claude-plugin" / "marketplace.json") + assert marketplace["renames"] == {"dataiku-headless-http": "headless-http"} + assert rendered_claude["name"] != source_claude["name"] assert rendered_codex["version"] == source_codex["version"] assert rendered_codex["description"] == source_codex["description"] assert rendered_claude["version"] == source_claude["version"] @@ -82,14 +102,14 @@ def test_renders_http_plugin_with_http_only_setup_skill(tmp_path): ).read_text(encoding="utf-8") assert setup == expected_setup assert "ask whether the user wants to authenticate now" in setup - assert "codex mcp login dataiku" in setup - assert "claude mcp login dataiku" in setup + assert "codex mcp login dku" in setup + assert "claude mcp login plugin:headless-http:dku" in setup assert "retry `list_instances`" in setup assert "uv" not in setup assert "API key" not in setup assert "Never call `configure_instance` or `delete_instance`" in setup assert not (output / "skills" / "dataiku-headless-setup" / "references").exists() - assert (ROOT / ".mcp.json").read_text(encoding="utf-8") == source_mcp + assert source_mcp_path.read_text(encoding="utf-8") == source_mcp def test_renders_zip_with_one_plugin_root(tmp_path): @@ -108,7 +128,9 @@ def test_renders_zip_with_one_plugin_root(tmp_path): assert archive.is_file() assert not output.exists() with zipfile.ZipFile(archive) as bundle: - assert "dataiku-headless-http/.mcp.json" in bundle.namelist() + assert "dataiku-headless-http/.codex-plugin/mcp.json" in bundle.namelist() + for name in ("plugin.json", "marketplace.json"): + assert f"dataiku-headless-http/.github/plugin/{name}" in bundle.namelist() assert "dataiku-headless-http/skills/dataiku-headless-setup/SKILL.md" in ( bundle.namelist() ) diff --git a/tests/test_instance_pinning.py b/tests/test_instance_pinning.py index c6754609..ae7b23f8 100644 --- a/tests/test_instance_pinning.py +++ b/tests/test_instance_pinning.py @@ -44,7 +44,7 @@ def test_tool_keeps_its_initial_instance_after_a_concurrent_switch(monkeypatch): api_key="key-a", no_check_certificate=False, source="config", - instance_type="automation", + instance_type="design", ) instance_b = DSSInstance( name="instance-b", @@ -62,7 +62,7 @@ def test_tool_keeps_its_initial_instance_after_a_concurrent_switch(monkeypatch): def get_client(): stdio._current_instance = instance_b observed_instances.append(request.get_pinned_instance().name) - assert request.get_pinned_instance().instance_type == "automation" + assert request.get_pinned_instance().instance_type == "design" return fake_client monkeypatch.setattr(projects, "get_dss_client", get_client) diff --git a/tests/test_instance_policy.py b/tests/test_instance_policy.py new file mode 100644 index 00000000..0e549048 --- /dev/null +++ b/tests/test_instance_policy.py @@ -0,0 +1,398 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Instance policy contracts and enforcement through MCP dispatch.""" + +import asyncio +import json +from contextvars import ContextVar +from types import SimpleNamespace +from typing import get_args + +import pytest +from fastmcp import Client + +from dataiku_mcp import mcp, server +from dataiku_mcp.config import http, request, stdio +from dataiku_mcp.config.models import DSSInstance, InstanceType +from dataiku_mcp.instance_policy import require_instance_type +from dataiku_mcp.tools import projects +from tests.test_tool_surface import EXPECTED_TOOLS_BY_MODULE +from tests.utils.fakes import FakeContext + +PROJECT_EDIT_TOOLS = EXPECTED_TOOLS_BY_MODULE["cobuild"] | { + "create_project", + "update_project_settings", + "set_project_variables", + "set_container_exec_config", + "create_upload_dataset", + "create_managed_folder", + "upload_file_to_managed_folder", + "write_project_library_file", +} +INSTANCE_TOOLS = EXPECTED_TOOLS_BY_MODULE["instances"] + + +def _instance(instance_type): + return DSSInstance( + name=instance_type, + url="https://example.com", + api_key="private-key", + api_ticket=None, + instance_type=instance_type, + source="config", + no_check_certificate=False, + ) + + +def _allowed(tool_name): + if tool_name in INSTANCE_TOOLS: + return set(get_args(InstanceType)) + if tool_name in PROJECT_EDIT_TOOLS: + return {"design"} + return {"design", "automation"} + + +def test_every_registered_tool_declares_its_exact_instance_policy(): + tools = asyncio.run(mcp.list_tools(run_middleware=False)) + assert len(tools) == sum(map(len, EXPECTED_TOOLS_BY_MODULE.values())) + for tool in tools: + allowed = tool.meta["allowed_instance_types"] + assert isinstance(allowed, list) + assert len(allowed) == len(set(allowed)) + assert set(allowed) == _allowed(tool.name), tool.name + for instance_type in get_args(InstanceType): + if instance_type in _allowed(tool.name): + require_instance_type(tool.name, instance_type, tool.meta) + else: + with pytest.raises(ValueError, match=f"{tool.name}.*{instance_type}"): + require_instance_type(tool.name, instance_type, tool.meta) + + +@pytest.mark.parametrize( + "metadata", + [ + None, + {}, + {"allowed_instance_types": None}, + {"allowed_instance_types": []}, + {"allowed_instance_types": "design"}, + {"allowed_instance_types": ["unknown"]}, + {"allowed_instance_types": [42]}, + {"allowed_instance_types": ["design", "design"]}, + ], +) +def test_invalid_policy_fails_closed_even_for_instance_controls(metadata): + with pytest.raises(ValueError, match="invalid instance-type policy"): + require_instance_type("switch_instance", None, metadata) + + +@pytest.mark.parametrize( + "instance_type", ["automation", "govern", "deployer", "agent-management"] +) +def test_mcp_dispatch_denies_every_disallowed_tool_before_validation_or_sdk( + monkeypatch, instance_type +): + monkeypatch.setattr(stdio, "_current_instance", _instance(instance_type)) + + async def dispatch(): + tools = await mcp.list_tools(run_middleware=False) + async with Client(mcp) as client: + for tool in tools: + if instance_type in _allowed(tool.name): + continue + result = await client.call_tool(tool.name, {}, raise_on_error=False) + assert result.is_error, tool.name + message = result.content[0].text + assert tool.name in message + assert instance_type in message + assert "Allowed types:" in message + assert "switch_instance" in message + assert "private-key" not in message + + asyncio.run(dispatch()) + + +@pytest.mark.parametrize("instance_type", ["design", "automation"]) +def test_mcp_allowed_read_and_recovery_after_denial(monkeypatch, instance_type): + monkeypatch.setattr(stdio, "_current_instance", _instance(instance_type)) + calls = [] + + def keys(): + calls.append(request.get_pinned_instance().instance_type) + return ["PROJECT"] + + monkeypatch.setattr( + projects, "get_dss_client", lambda: SimpleNamespace(list_project_keys=keys) + ) + + async def dispatch(): + async with Client(mcp) as client: + stdio._current_instance = _instance("govern") + rejected = await client.call_tool( + "count_projects", {}, raise_on_error=False + ) + assert rejected.is_error + stdio._current_instance = _instance(instance_type) + result = await client.call_tool("count_projects", {}) + assert json.loads(result.content[0].text) == {"project_count": 1} + + asyncio.run(dispatch()) + assert calls == [instance_type] + + +@pytest.mark.parametrize("instance_type", ["govern", "deployer", "agent-management"]) +def test_mcp_unsupported_status_needs_no_client(monkeypatch, instance_type): + from dataiku_mcp.tools import instances + + monkeypatch.setattr(stdio, "_current_instance", _instance(instance_type)) + monkeypatch.setattr( + instances, "get_dss_client", lambda: pytest.fail("No SDK call is allowed") + ) + + async def dispatch(): + async with Client(mcp) as client: + result = await client.call_tool("get_current_instance", {}) + data = json.loads(result.content[0].text) + assert data["connection_status"] == "unsupported" + assert "dataiku_version" not in data + assert "private-key" not in result.content[0].text + + asyncio.run(dispatch()) + + +@pytest.mark.parametrize( + "tool_name", + ["list_instances", "switch_instance", "configure_instance", "delete_instance"], +) +def test_local_controls_dispatch_without_a_selection(monkeypatch, tool_name): + monkeypatch.setattr(stdio, "get_current_instance", lambda: None) + monkeypatch.setattr(stdio, "get_instances", lambda: {}) + + async def dispatch(): + context = SimpleNamespace( + message=SimpleNamespace(name=tool_name), fastmcp_context=None + ) + + async def handler(_): + assert request._pinned_instance.get() is None + return "local-result" + + assert ( + await server.RequestContextMiddleware().on_call_tool(context, handler) + == "local-result" + ) + + asyncio.run(dispatch()) + + +@pytest.mark.parametrize( + "outcome", ["denial", "exchange-error", "handler-error", "cancelled"] +) +def test_http_request_context_is_restored_on_all_failure_paths(monkeypatch, outcome): + instances = {"design": _instance("design"), "govern": _instance("govern")} + selected = "govern" if outcome == "denial" else "design" + monkeypatch.setattr( + http, + "get_instances_and_selections", + lambda: (instances, {"issuer": {"alice": selected}}), + ) + monkeypatch.setattr( + server, + "get_access_token", + lambda: SimpleNamespace( + token="mcp-token", claims={"iss": "issuer", "sub": "alice"} + ), + ) + + async def exchange(_): + if outcome == "exchange-error": + raise RuntimeError("exchange failed") + return "delegated-token" + + monkeypatch.setattr(server, "exchange_http_token", exchange) + + async def dispatch(): + async def handler(_): + assert request.get_http_dss_token() == "delegated-token" + if outcome == "cancelled": + raise asyncio.CancelledError() + raise RuntimeError("handler failed") + + context = SimpleNamespace( + message=SimpleNamespace(name="count_projects"), fastmcp_context=None + ) + expected = ( + ValueError + if outcome == "denial" + else asyncio.CancelledError + if outcome == "cancelled" + else RuntimeError + ) + with pytest.raises(expected): + await server.RequestContextMiddleware().on_call_tool(context, handler) + assert request._pinned_instance.get() is None + assert not request.is_http_request() + with pytest.raises(ValueError, match="No delegated DSS token"): + request.get_http_dss_token() + + asyncio.run(dispatch()) + + +def test_concurrent_http_users_keep_separate_policies_and_sdk_targets(monkeypatch): + profiles = {kind: _instance(kind) for kind in ("design", "govern")} + selections = {"issuer": {"alice": "design", "bob": "govern"}} + monkeypatch.setattr( + http, "get_instances_and_selections", lambda: (profiles, selections) + ) + identity = ContextVar("test_identity") + monkeypatch.setattr( + server, + "get_access_token", + lambda: SimpleNamespace( + token="mcp-token", claims={"iss": "issuer", "sub": identity.get()} + ), + ) + exchanges, sdk_calls = [], [] + + async def exchange(_): + exchanges.append(request.get_request_owner()[-1]) + selections["issuer"]["alice"] = "govern" + await asyncio.sleep(0) + return "delegated-token" + + monkeypatch.setattr(server, "exchange_http_token", exchange) + + def keys(): + sdk_calls.append( + ( + request.get_request_owner()[-1], + request.get_pinned_instance().instance_type, + ) + ) + return ["PROJECT"] + + monkeypatch.setattr( + projects, "get_dss_client", lambda: SimpleNamespace(list_project_keys=keys) + ) + + async def call(user): + token = identity.set(user) + try: + context = SimpleNamespace( + message=SimpleNamespace(name="count_projects"), fastmcp_context=None + ) + + async def handler(_): + return await projects.count_projects(FakeContext()) + + if user == "bob": + with pytest.raises(ValueError, match="govern"): + await server.RequestContextMiddleware().on_call_tool( + context, handler + ) + else: + result = await server.RequestContextMiddleware().on_call_tool( + context, handler + ) + assert json.loads(result) == {"project_count": 1} + assert not request.is_http_request() + assert request._pinned_instance.get() is None + finally: + identity.reset(token) + + async def dispatch(): + await asyncio.gather(call("alice"), call("bob")) + + asyncio.run(dispatch()) + assert exchanges == ["alice"] + assert sdk_calls == [("alice", "design")] + + +def test_mcp_dispatch_fails_closed_when_registered_policy_is_missing(monkeypatch): + monkeypatch.setattr(stdio, "_current_instance", _instance("design")) + + async def dispatch(): + tool = await mcp.get_tool("count_projects") + monkeypatch.setattr(tool, "meta", {}) + async with Client(mcp) as client: + result = await client.call_tool("count_projects", {}, raise_on_error=False) + assert result.is_error + assert "invalid instance-type policy" in result.content[0].text + + asyncio.run(dispatch()) + + +def test_mcp_dispatch_keeps_concurrent_http_identities_separate(monkeypatch): + profiles = {kind: _instance(kind) for kind in ("design", "govern")} + selections = {"issuer": {"alice": "design", "bob": "govern"}} + monkeypatch.setattr( + http, "get_instances_and_selections", lambda: (profiles, selections) + ) + identity = ContextVar("mcp_test_identity") + monkeypatch.setattr( + server, + "get_access_token", + lambda: SimpleNamespace( + token="mcp-token", claims={"iss": "issuer", "sub": identity.get()} + ), + ) + exchanges, sdk_calls = [], [] + + async def exchange(_): + exchanges.append(request.get_request_owner()[-1]) + selections["issuer"]["alice"] = "govern" + await asyncio.sleep(0) + return "delegated-token" + + monkeypatch.setattr(server, "exchange_http_token", exchange) + + def keys(): + sdk_calls.append( + ( + request.get_request_owner()[-1], + request.get_pinned_instance().instance_type, + ) + ) + assert request.get_http_dss_token() == "delegated-token" + return ["PROJECT"] + + monkeypatch.setattr( + projects, "get_dss_client", lambda: SimpleNamespace(list_project_keys=keys) + ) + + async def call(user): + token = identity.set(user) + try: + async with Client(mcp) as client: + result = await client.call_tool( + "count_projects", {}, raise_on_error=False + ) + if user == "bob": + assert result.is_error + assert "govern" in result.content[0].text + else: + assert not result.is_error + assert json.loads(result.content[0].text) == {"project_count": 1} + assert not request.is_http_request() + assert request._pinned_instance.get() is None + finally: + identity.reset(token) + + async def dispatch(): + await asyncio.gather(call("alice"), call("bob")) + + asyncio.run(dispatch()) + assert exchanges == ["alice"] + assert sdk_calls == [("alice", "design")] diff --git a/tests/test_instances.py b/tests/test_instances.py index cc940c4d..be1b98fa 100644 --- a/tests/test_instances.py +++ b/tests/test_instances.py @@ -19,6 +19,7 @@ from dataclasses import replace import pytest +from requests.exceptions import SSLError from dataiku_mcp.config import request from dataiku_mcp.config.models import DSSInstance @@ -95,7 +96,13 @@ def unexpected_client(): monkeypatch.setattr(instances, "get_dss_client", lambda: client) current = asyncio.run(instances.get_current_instance(FakeContext())) assert json.loads(current)["instance_type"] == instance_type - assert client.info_calls == 1 + unsupported = instance_type in {"govern", "deployer", "agent-management"} + assert client.info_calls == (0 if unsupported else 1) + assert json.loads(current)["connection_status"] == ( + "unsupported" if unsupported else "connected" + ) + if unsupported: + assert "dataiku_version" not in json.loads(current) for response in (listing, switching, current): assert API_KEY not in response assert "api_key" not in response @@ -107,6 +114,7 @@ def test_get_current_instance_reports_the_dataiku_version(monkeypatch): assert result["dataiku_version"] == "14.7.2" assert result["connection_status"] == "connected" + assert "error" not in result assert client.info_calls == 1 @@ -132,16 +140,24 @@ def missing_client(): assert result["instance_type"] == "design" -def test_get_current_instance_reports_failed_connection(monkeypatch): +@pytest.mark.parametrize( + "error", + [ + ConnectionError("connection failed"), + SSLError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate has expired"), + ], +) +def test_get_current_instance_reports_failed_connection(monkeypatch, error): client = _FakeClient({}) def unavailable_instance_info(): - raise ConnectionError("connection failed") + raise error client.get_instance_info = unavailable_instance_info result = _current(monkeypatch, client) assert result["connection_status"] == "failed" + assert result["error"] == str(error) assert "dataiku_version" not in result assert client.info_calls == 0 @@ -180,3 +196,176 @@ def get_client(): assert result["name"] == instance.name assert result["source"] == instance.source assert result["connection_status"] == ("connected" if connected else "failed") + + +@pytest.mark.parametrize( + "elicitation", ["unsupported", "accept", "decline", "expire", "cancel"] +) +def test_connection_manager_tool_completion(monkeypatch, elicitation): + import threading + from types import SimpleNamespace + + completed = threading.Event() + waiting = threading.Event() + closed = [] + if elicitation != "cancel": + completed.set() + + def wait(timeout): + waiting.set() + return completed.wait(timeout) + + def close(): + closed.append(True) + completed.set() + + session = SimpleNamespace( + url="http://127.0.0.1:1234/token", + browser_opened=False, + completed=SimpleNamespace(wait=wait), + result=None + if elicitation == "expire" + else {"active_instance": "sandbox", "connection_count": 2}, + expired=elicitation == "expire", + close=close, + ) + starts = [] + + def start(**kwargs): + starts.append(kwargs) + return session + + monkeypatch.setattr(instances, "start_setup_server", start) + monkeypatch.setattr(request, "is_http_request", lambda: False) + + class Session: + client_params = SimpleNamespace( + capabilities=SimpleNamespace( + elicitation=( + None if elicitation == "unsupported" else SimpleNamespace(url={}) + ) + ) + ) + + async def elicit_url(self, **kwargs): + return SimpleNamespace( + action="decline" if elicitation == "decline" else "accept" + ) + + async def send_elicit_complete(self, *args, **kwargs): + pass + + ctx = FakeContext() + ctx.session = Session() + ctx.request_id = "request" + + async def invoke(): + task = asyncio.create_task(instances.configure_instance(ctx)) + if elicitation == "cancel": + try: + assert await asyncio.to_thread(waiting.wait, 2) + task.cancel() + await task + finally: + # A failed cleanup assertion must not leave the test runner waiting. + completed.set() + return await task + + if elicitation == "cancel": + with pytest.raises(asyncio.CancelledError): + asyncio.run(invoke()) + assert closed == [True] + return + result = json.loads(asyncio.run(invoke())) + if elicitation in {"unsupported", "decline"}: + assert result["interaction_mode"] == "direct_local_url" + assert result["setup_url"] == session.url + assert len(starts) == (2 if elicitation == "decline" else 1) + elif elicitation == "expire": + assert result["status"] == "expired" + else: + assert result["status"] == "completed" + assert result["state"]["active_instance"] == "sandbox" + + +@pytest.mark.parametrize("elicitation", ["unsupported", "accept", "decline"]) +@pytest.mark.parametrize("cancellation", ["task", "scope"]) +def test_connection_manager_cancellation_during_startup( + monkeypatch, elicitation, cancellation +): + import threading + from types import SimpleNamespace + + import anyio + + started = threading.Event() + release = threading.Event() + closed = threading.Event() + starts = [] + session = SimpleNamespace(close=closed.set, url="http://127.0.0.1:1234/token") + + def start(**kwargs): + starts.append(kwargs) + if elicitation != "decline" or len(starts) == 2: + started.set() + if not release.wait(5): + raise TimeoutError("Test did not release server startup") + return session + + class Session: + client_params = SimpleNamespace( + capabilities=SimpleNamespace( + elicitation=( + None if elicitation == "unsupported" else SimpleNamespace(url={}) + ) + ) + ) + + async def elicit_url(self, **kwargs): + return SimpleNamespace(action="decline") + + monkeypatch.setattr(instances, "start_setup_server", start) + monkeypatch.setattr(request, "is_http_request", lambda: False) + ctx = FakeContext() + ctx.session = Session() + ctx.request_id = "request" + + async def invoke(): + scope = None + + async def configure(): + nonlocal scope + with anyio.CancelScope() as scope: + await instances.configure_instance(ctx) + + task = asyncio.create_task( + configure() + if cancellation == "scope" + else instances.configure_instance(ctx) + ) + try: + assert await asyncio.to_thread(started.wait, 2) + if cancellation == "scope": + scope.cancel() + else: + task.cancel() + # Let cancellation reach the tool while startup is still blocked. + await asyncio.sleep(0) + release.set() + if cancellation == "scope": + await task + else: + with pytest.raises(asyncio.CancelledError): + await task + assert closed.is_set() + finally: + release.set() + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(invoke()) + assert len(starts) == (2 if elicitation == "decline" else 1) diff --git a/tests/test_launcher_cli.py b/tests/test_launcher_cli.py index 29f7c08b..cfd68f1f 100644 --- a/tests/test_launcher_cli.py +++ b/tests/test_launcher_cli.py @@ -34,6 +34,8 @@ def _install_fake_launcher_modules(monkeypatch) -> list[tuple]: events = [] dotenv_module = ModuleType("dotenv") + truststore_module = ModuleType("truststore") + truststore_module.inject_into_ssl = lambda: events.append(("system_trust",)) dataiku_mcp_module = ModuleType("dataiku_mcp") def load_dotenv(path: Path, *, override: bool) -> None: @@ -47,6 +49,7 @@ def load_dotenv(path: Path, *, override: bool) -> None: ("run", "http", path) ) monkeypatch.setitem(sys.modules, "dotenv", dotenv_module) + monkeypatch.setitem(sys.modules, "truststore", truststore_module) monkeypatch.setitem(sys.modules, "dataiku_mcp", dataiku_mcp_module) return events @@ -63,6 +66,7 @@ def test_launcher_loads_dotenv_before_importing_and_running_server(monkeypatch): assert events == [ ("load_dotenv", SCRIPT.parent.parent / ".env", False), + ("system_trust",), ("run", "http", Path("/tmp/http.json")), ] @@ -101,6 +105,7 @@ def test_launcher_passes_settings_path_to_stdio(monkeypatch): assert events == [ ("load_dotenv", SCRIPT.parent.parent / ".env", False), + ("system_trust",), ("run", "stdio", Path("/tmp/stdio.json")), ] diff --git a/tests/test_onboarding_ui.py b/tests/test_onboarding_ui.py index fc1a0e55..98536528 100644 --- a/tests/test_onboarding_ui.py +++ b/tests/test_onboarding_ui.py @@ -12,41 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dataiku_mcp.setup_server import DATAIKU_BIRD_SVG, _page, _success_page +import json +import re +from dataiku_mcp.setup_server import _page -def test_setup_page_uses_dataiku_branding_and_accessible_states(): - page = _page(error="Check the URL.") - assert DATAIKU_BIRD_SVG in page - assert '
' in page - assert "Connect to Dataiku" in page - assert "Your credentials stay on this machine." in page - assert 'role="alert"' in page - assert "Check the URL." in page - assert "prefers-reduced-motion:reduce" in page - assert "gradient" not in page - - -def test_setup_page_hides_certificate_option_in_advanced_options_and_requires_test(): - page = _page() - - assert "Advanced options" in page - assert 'name="no_check_certificate"' in page - assert 'name="action" value="save" disabled' in page - assert "button:disabled, button:disabled:hover" in page - - -def test_setup_page_enables_save_after_a_successful_connection_test(): - page = _page(connection_validated=True) - - assert 'name="action" value="save" disabled' not in page - - -def test_success_page_uses_dataiku_branding_and_escapes_instance_name(): - page = _success_page("") - - assert DATAIKU_BIRD_SVG in page - assert "Dataiku is connected" in page - assert "<production>" in page - assert "" not in page +def test_state_embedding_prevents_script_injection_and_round_trips(): + state = {"connections": [{"name": ""}]} + page = _page(state, nonce="test-nonce") + payload = re.search(r'type="application/json">(.*?)', page, re.S).group(1) + assert json.loads(payload) == state + assert "" not in payload + assert 'nonce="test-nonce"' in page diff --git a/tests/test_plugin_futures.py b/tests/test_plugin_futures.py new file mode 100644 index 00000000..7eb5774b --- /dev/null +++ b/tests/test_plugin_futures.py @@ -0,0 +1,127 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Plugin future follow-up must not expose settings or Git error output.""" + +import asyncio +import json +from unittest.mock import patch + +import pytest +from dataikuapi import DSSClient +from dataikuapi.utils import DataikuException + +from dataiku_mcp.tools import jobs +from tests.utils.fakes import FakeContext + + +@pytest.mark.parametrize("fetch_result", [False, True]) +@pytest.mark.parametrize("failure", [False, True]) +def test_plugin_future_redacts_logs_errors_and_settings(fetch_result, failure): + client = DSSClient("https://synthetic.example.com", "synthetic-key") + + def response(*args, **kwargs): + return { + "jobId": "F", + "alive": False, + "hasResult": True, + "payload": {"action": "plugin_install", "targets": ["SYNTHETIC_SECRET"]}, + "log": "SYNTHETIC_SECRET", + "result": { + "success": not failure, + "needsReload": True, + "pluginDesc": {"desc": {"id": "fixture", "version": "1.0"}}, + "settings": {"token": "SYNTHETIC_SECRET"}, + **( + { + "installationError": { + "message": "https://user:SYNTHETIC_SECRET@example.com/repo" + } + } + if failure + else {} + ), + }, + } + + with ( + patch.object(client, "_perform_json", side_effect=response), + patch.object(jobs, "get_dss_client", return_value=client), + ): + result = json.loads( + asyncio.run( + jobs.get_future_status("F", FakeContext(), fetch_result=fetch_result) + ) + ) + assert "SYNTHETIC_SECRET" not in json.dumps(result) + receipt = result["state"]["result"] + assert receipt["success"] is not failure + assert receipt["pluginDesc"]["desc"]["id"] == "fixture" + assert ("error" in receipt) is failure + + +@pytest.mark.parametrize("stage", ["peek", "get"]) +def test_future_transport_error_cannot_expose_git_output(stage): + client = DSSClient("https://synthetic.example.com", "synthetic-key") + + def response(*args, **kwargs): + if stage == "peek" or not kwargs["params"]["peek"]: + raise DataikuException("Git failed: SYNTHETIC_SECRET") + return {"payload": {"action": "plugin_install"}} + + with ( + patch.object(client, "_perform_json", side_effect=response), + patch.object(jobs, "get_dss_client", return_value=client), + pytest.raises(RuntimeError) as error, + ): + asyncio.run(jobs.get_future_status("F", FakeContext(), fetch_result=True)) + assert "SYNTHETIC_SECRET" not in str(error.value) + + +def test_other_future_results_are_preserved(): + client = DSSClient("https://synthetic.example.com", "synthetic-key") + state = {"payload": {"action": "other"}, "result": {"value": 42}} + with ( + patch.object(client, "_perform_json", return_value=state), + patch.object(jobs, "get_dss_client", return_value=client), + ): + result = json.loads( + asyncio.run(jobs.get_future_status("F", FakeContext(), fetch_result=True)) + ) + assert result["state"] == state + + +def test_plugin_future_preserves_progress_without_exposing_other_details(): + client = DSSClient("https://synthetic.example.com", "synthetic-key") + progress = {"name": "Building CDE image", "unit": "images", "cur": 2, "target": 5} + state = { + "alive": True, + "hasResult": False, + "runningTime": 600000, + "payload": {"action": "plugin_install", "targets": ["SYNTHETIC_SECRET"]}, + "progress": { + "states": [{**progress, "details": "SYNTHETIC_SECRET"}], + "extra": "SYNTHETIC_SECRET", + }, + "log": "SYNTHETIC_SECRET", + } + with ( + patch.object(client, "_perform_json", return_value=state), + patch.object(jobs, "get_dss_client", return_value=client), + ): + result = json.loads(asyncio.run(jobs.get_future_status("F", FakeContext()))) + assert result["state"]["runningTime"] == 600000 + assert result["state"]["progress"] == {"states": [progress]} + assert result["state"]["alive"] is True + assert "SYNTHETIC_SECRET" not in json.dumps(result) diff --git a/tests/test_plugin_packaging.py b/tests/test_plugin_packaging.py index 6bedf5df..94614a7f 100644 --- a/tests/test_plugin_packaging.py +++ b/tests/test_plugin_packaging.py @@ -15,7 +15,13 @@ """Packaging invariants for the public stdio plugin.""" import json +import shutil +import subprocess +import sys from pathlib import Path +from string import Template + +import pytest ROOT = Path(__file__).resolve().parents[1] @@ -25,9 +31,21 @@ def _json(path: str) -> dict: return json.loads((ROOT / path).read_text(encoding="utf-8")) +@pytest.mark.parametrize("host", ["claude", "codex", "cortex", "cursor", "github"]) +def test_plugin_identity_matches_host(host): + path = ( + ".github/plugin/plugin.json" + if host == "github" + else f".{host}-plugin/plugin.json" + ) + assert _json(path)["name"] == ( + "headless" if host == "claude" else "dataiku-headless" + ) + + def test_marketplace_plugin_uses_stdio(): claude = _json(".claude-plugin/plugin.json") - bundled_mcp = _json(".mcp.json") + bundled_mcp = _json(_json(".codex-plugin/plugin.json")["mcpServers"]) assert claude["mcpServers"]["dataiku"]["type"] == "stdio" assert bundled_mcp["mcpServers"]["dataiku"]["args"][-2:] == [ @@ -36,15 +54,38 @@ def test_marketplace_plugin_uses_stdio(): ] -def test_marketplace_entry_matches_plugin_identity(): - claude = _json(".claude-plugin/plugin.json") - marketplace = _json(".claude-plugin/marketplace.json") - - entries = [ - entry for entry in marketplace["plugins"] if entry["name"] == claude["name"] - ] - assert len(entries) == 1 - assert entries[0]["source"] == "./" +@pytest.mark.parametrize( + "directory, manifest_path", + [ + (".claude-plugin", ".claude-plugin/plugin.json"), + (".github/plugin", ".github/plugin/plugin.json"), + (".cursor-plugin", ".cursor-plugin/plugin.json"), + (".agents/plugins", ".codex-plugin/plugin.json"), + ], +) +def test_marketplace_entry_matches_plugin_identity(directory, manifest_path): + manifest = _json(manifest_path) + marketplace = _json(f"{directory}/marketplace.json") + assert len(marketplace["plugins"]) == 1 + entry = marketplace["plugins"][0] + assert entry["name"] == manifest["name"] + assert entry["source"] == "./" + assert (ROOT / entry["source"]).resolve() == ROOT + assert (ROOT / entry["source"] / manifest_path).is_file() + assert marketplace["name"] == "dataiku" + if directory == ".claude-plugin": + assert marketplace["renames"]["dataiku-headless"] == "headless" + else: + assert "renames" not in marketplace + + +def test_codex_marketplace_declares_installation_and_authentication_policy(): + entry = _json(".agents/plugins/marketplace.json")["plugins"][0] + assert entry["policy"] == { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + } + assert entry["category"] == "developer-tools" def test_public_setup_skill_is_stdio_only(): @@ -58,7 +99,8 @@ def test_public_setup_skill_is_stdio_only(): def test_environment_configuration_is_passed_through(): - variables = _json(".mcp.json")["mcpServers"]["dataiku"]["env_vars"] + bundled_mcp = _json(_json(".codex-plugin/plugin.json")["mcpServers"]) + variables = bundled_mcp["mcpServers"]["dataiku"]["env_vars"] assert { "DKU_DSS_URL", "DKU_API_KEY", @@ -71,3 +113,78 @@ def test_environment_configuration_is_passed_through(): "DKU_API_TICKET", "DKU_SERVER_CERT", }.issubset(variables) + + +def test_copilot_launches_from_an_unrelated_working_directory(tmp_path): + plugin_root = tmp_path / "installed plugin" + runtime = plugin_root / "runtime" + runtime.mkdir(parents=True) + shutil.copy2(ROOT / "runtime" / "run_mcp.py", runtime / "run_mcp.py") + for relative in (".github/plugin/plugin.json", ".claude-plugin/plugin.json"): + target = plugin_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / relative, target) + claude_path = plugin_root / ".claude-plugin/plugin.json" + claude = json.loads(claude_path.read_text()) + claude["mcpServers"]["dataiku"]["args"] = ["missing-script.py"] + claude_path.write_text(json.dumps(claude)) + # Documented legacy discovery order; this is a launch replay, not a native CLI test. + assert not (ROOT / ".mcp.json").exists() + assert not (ROOT / ".github/mcp.json").exists() + for relative in ( + ".plugin/plugin.json", + "plugin.json", + ".github/plugin/plugin.json", + ".claude-plugin/plugin.json", + ): + assert (plugin_root / relative).exists() == (ROOT / relative).exists() + manifest_path = next( + plugin_root / relative + for relative in ( + ".plugin/plugin.json", + "plugin.json", + ".github/plugin/plugin.json", + ".claude-plugin/plugin.json", + ) + if (plugin_root / relative).exists() + ) + assert manifest_path == plugin_root / ".github/plugin/plugin.json" + manifest = json.loads(manifest_path.read_text()) + assert manifest["name"] == "dataiku-headless" + assert (ROOT / manifest["skills"]).is_dir() + server = manifest["mcpServers"]["dataiku"] + assert server["command"] == "uv" + assert server["type"] == "stdio" + assert server["args"] == [ + "run", + "--quiet", + "--locked", + "--script", + "${PLUGIN_ROOT}/runtime/run_mcp.py", + "--transport", + "stdio", + ] + assert server["cwd"] == "${PLUGIN_ROOT}" + session_directory = tmp_path / "customer project" + session_directory.mkdir() + variables = { + "PLUGIN_ROOT": str(plugin_root), + "CLAUDE_PLUGIN_ROOT": str(plugin_root), + "COPILOT_PLUGIN_ROOT": str(plugin_root), + } + cwd = Path(Template(server.get("cwd", "${PLUGIN_ROOT}")).substitute(variables)) + if not cwd.is_absolute(): + cwd = session_directory / cwd + args = [Template(arg).substitute(variables) for arg in server["args"]] + script_index = args.index("--script") + 1 + result = subprocess.run( + [sys.executable, *args[script_index:], "--help"], + cwd=cwd, + env=variables, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "Run the Dataiku MCP server." in result.stdout diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9804a5ed..f347b03b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -127,8 +127,22 @@ def __init__(self, job_id, states): def get_state(self): if self._pending: self.state = self._pending.pop(0) + self.read = True return self.state + def peek_state(self): + return self.peeked + + +def _build_future(job_id, env_name, states): + """A plugin code-env build, whose peeked state names its target environment.""" + future = FakeFuture(job_id, states) + future.peeked = { + "jobId": job_id, + "payload": {"action": "import_env", "displayName": f"Import env: {env_name}"}, + } + return future + class FakePluginSettings: def __init__(self, raw): @@ -137,6 +151,12 @@ def __init__(self, raw): def get_raw(self): return self.raw + def set_code_env(self, name): + self.raw["codeEnvName"] = name + + def save(self): + self.saves = getattr(self, "saves", 0) + 1 + class FakeUsages: def __init__(self, raw): @@ -171,11 +191,22 @@ def update_from_store(self): self.on_change() return self.futures["update"] + def update_from_git(self, repository_url, checkout, subpath): + self.git_update_args = (repository_url, checkout, subpath) + self.on_change() + return self.futures["update"] + def start_update_from_zip(self, fp): self.uploaded = fp.read() self.on_change() return self.futures["update"] + def create_code_env(self, python_interpreter=None): + self.created_interpreter = python_interpreter + if getattr(self, "create_error", None): + raise self.create_error + return self.futures["create_code_env"] + def update_code_env(self): return self.futures["update_code_env"] @@ -187,7 +218,13 @@ def delete(self, force=False): class FakeClient: - def __init__(self, listing=None, plugins=None, listing_after_change=None): + host = "https://synthetic.example.com" + + def __init__( + self, listing=None, plugins=None, listing_after_change=None, code_envs=None + ): + self.code_envs = code_envs or [] + self.futures = {} self.listing = listing or [] self.plugins = plugins or {} self.listing_after_change = listing_after_change @@ -205,6 +242,35 @@ def list_plugins(self): def get_plugin(self, plugin_id): return self.plugins[plugin_id] + def list_code_envs(self): + return self.code_envs + + def install_plugin_from_store(self, plugin_id): + self.store_install_id = plugin_id + self._apply_change() + return self.futures["install"] + + def install_plugin_from_git(self, repository_url, checkout, subpath): + self.git_install_args = (repository_url, checkout, subpath) + self._apply_change() + return self.futures["install"] + + def start_install_plugin_from_archive(self, archive): + self.uploaded = archive.read() + self._apply_change() + return self.futures["install"] + + +def _absent(plugin_id="geocoder", futures=None, version="1.0.0", **kwargs): + plugin = FakePlugin(plugin_id) + client = FakeClient( + plugins={plugin_id: plugin}, + listing_after_change=[_listing_entry(plugin_id, version)], + ) + client.futures.update(futures or {}) + client.futures.update(kwargs) + return plugin, client + def _installed(plugin_id="geocoder", version="1.0.0", **kwargs): """A client where ``plugin_id`` is already installed.""" @@ -239,6 +305,7 @@ def _isolated(monkeypatch): monkeypatch.setattr(tools, "_MIN_POLL_INTERVAL_SECONDS", 0) monkeypatch.setattr(tools, "_MAX_POLL_INTERVAL_SECONDS", 0) monkeypatch.setattr(tools, "get_dss_client", _no_client) + tools._BUILD_OWNERS.clear() def _patch_client(client): @@ -265,22 +332,13 @@ def _members(payload): return sorted(zipfile.ZipFile(io.BytesIO(payload)).namelist()) -@pytest.mark.parametrize( - ("source", "plugin_id", "local_path"), - [("store", "my-plugin", None), ("local_path", None, "/server/plugin.zip")], -) -def test_update_plugin_is_unavailable_in_http_mode(source, plugin_id, local_path): +def test_update_plugin_local_path_is_unavailable_in_http_mode(): token = request.bind_http_identity("https://idp.example", "alice") try: - with pytest.raises( - ValueError, match="update_plugin is unavailable in HTTP mode" - ): + with pytest.raises(ValueError, match="unavailable in HTTP mode"): asyncio.run( tools.update_plugin( - source, - FakeContext(), - plugin_id=plugin_id, - local_path=local_path, + "local_path", FakeContext(), local_path="/server/plugin.zip" ) ) finally: @@ -603,8 +661,6 @@ def test_update_plugin_rejects_arguments_that_do_not_match_the_source(): asyncio.run(tools.update_plugin("store", FakeContext())) with pytest.raises(ValueError, match="local_path"): asyncio.run(tools.update_plugin("local_path", FakeContext())) - with pytest.raises(ValueError, match="source"): - asyncio.run(tools.update_plugin("git", FakeContext(), plugin_id="x")) def test_update_plugin_zips_a_directory_and_omits_version_control_files(tmp_path): @@ -787,8 +843,8 @@ def test_update_plugin_reports_an_unbound_code_env_instead_of_failing(): assert res["status"] == "completed" assert res["code_env_rebuild"]["status"] == "skipped" - assert "Dataiku UI" in res["code_env_rebuild"]["hint"] - assert "does not establish" in res["code_env_rebuild"]["hint"] + assert "ensure_plugin_code_env" in res["code_env_rebuild"]["hint"] + assert "whether the plugin needs" in res["code_env_rebuild"]["hint"] def test_update_plugin_keeps_a_landed_update_when_the_rebuild_fails(): @@ -813,9 +869,58 @@ def test_update_plugin_keeps_a_landed_update_when_the_rebuild_fails(): assert res["code_env_rebuild"]["status"] == "failed" assert "pip resolution failed" in res["code_env_rebuild"]["error"] assert "Dataiku UI" in res["code_env_rebuild"]["hint"] + assert "recreate" in res["code_env_rebuild"]["hint"] assert "update_plugin" not in res["code_env_rebuild"]["hint"] +@pytest.mark.parametrize("stage", ["inspect", "start", "poll", "terminal"]) +def test_update_plugin_keeps_landed_update_after_rebuild_exception(monkeypatch, stage): + from requests.exceptions import Timeout + + future = FakeFuture("F3", [_alive()]) + plugin, client = _installed( + settings={"codeEnvName": "plugin_geocoder"}, + listing_after_change=[_listing_entry("geocoder", version="1.4.0")], + futures={"update": FakeFuture("F2", [_done()]), "update_code_env": future}, + ) + + def fail(): + raise Timeout("sensitive transport detail") + + if stage == "inspect": + monkeypatch.setattr(plugin, "get_settings", fail) + elif stage == "start": + monkeypatch.setattr(plugin, "update_code_env", fail) + elif stage == "poll": + monkeypatch.setattr(future, "get_state", fail) + else: + future.state = {"alive": False, "aborted": True, "hasResult": False} + + with _patch_client(client): + result = _load( + tools.update_plugin( + "store", + FakeContext(), + plugin_id="geocoder", + rebuild_code_env=True, + ) + ) + + assert result["status"] == "completed" + assert result["plugin"]["version"] == "1.4.0" + rebuild = result["code_env_rebuild"] + assert rebuild["status"] == "unknown" + assert "Do not repeat update_plugin" in rebuild["hint"] + assert "sensitive" not in json.dumps(result) + if stage != "inspect": + assert rebuild["code_env_name"] == "plugin_geocoder" + if stage in ("poll", "terminal"): + assert rebuild["future_id"] == "F3" + assert "get_future_status" in rebuild["hint"] + else: + assert "future_id" not in rebuild + + def test_update_plugin_timeout_says_the_requested_rebuild_never_started(): """Absent code_env_rebuild would read as a rebuild that quietly succeeded.""" _, client = _installed( @@ -1124,3 +1229,811 @@ async def _record(seconds): asyncio.run(tools._wait_for_future(future, 600)) assert slept and all(interval == 10 for interval in slept) + + +@pytest.mark.parametrize("http", [False, True]) +def test_install_plugin_from_store_reports_completion_and_restart_need( + monkeypatch, http +): + monkeypatch.setattr(request, "is_http_request", lambda: http) + _, client = _absent( + version="1.3.1", + install=FakeFuture( + "F1", [_alive(), _done(needsRestart=True, needsReload=True)] + ), + ) + + with _patch_client(client): + res = _load(tools.install_plugin("store", FakeContext(), plugin_id="geocoder")) + + assert res["status"] == "completed" + assert res["operation"] == "install" + assert res["source"] == "store" + assert res["needs_restart"] is True + assert res["needs_reload"] is True + assert res["plugin"]["version"] == "1.3.1" + assert client.store_install_id == "geocoder" + + +def test_install_plugin_refuses_an_already_installed_plugin(): + _, client = _installed(version="1.3.1") + + with _patch_client(client), pytest.raises(ValueError) as excinfo: + asyncio.run(tools.install_plugin("store", FakeContext(), plugin_id="geocoder")) + + assert "update_plugin" in str(excinfo.value) + + +# --------------------------------------------------------------------------- # + + +def test_local_install_is_disabled_in_http_before_any_io(monkeypatch): + monkeypatch.setattr(request, "is_http_request", lambda: True) + monkeypatch.setattr( + tools, "run_blocking", lambda *args: pytest.fail("must fail before IO") + ) + with pytest.raises(ValueError, match="unavailable in HTTP"): + _load( + tools.install_plugin( + "local_path", FakeContext(), local_path="/server/plugin.zip" + ) + ) + + +@pytest.mark.parametrize( + ("directory", "language"), [("python", "python"), ("R", "r"), (None, None)] +) +@pytest.mark.parametrize("wrapped", [False, True]) +def test_install_reads_code_env_descriptors_in_normalized_archive( + tmp_path, directory, language, wrapped +): + archive = tmp_path / "sample.zip" + prefix = "wrapper/" if wrapped else "" + with zipfile.ZipFile(archive, "w") as output: + output.writestr(prefix + "plugin.json", json.dumps({"id": "sample"})) + if directory: + output.writestr(prefix + f"code-env/{directory}/desc.json", "{}") + _, client = _absent("sample", {"install": FakeFuture(None, [_done()])}) + with _patch_client(client): + result = _load( + tools.install_plugin("local_path", FakeContext(), local_path=str(archive)) + ) + assert result["code_env"]["status"] == ("required" if language else "not_required") + assert result["code_env"]["languages"] == ([language] if language else []) + + +def _creation(client=None, states=None): + if client is None: + plugin, client = _installed() + else: + plugin = client.get_plugin("geocoder") + future = FakeFuture("F4", states or [_code_env_done("plugin_geocoder_managed")]) + plugin.futures["create_code_env"] = future + return plugin, client, future + + +def test_ensure_plugin_code_env_creates_and_verifies_binding(): + plugin, client, future = _creation() + with _patch_client(client): + result = _load( + tools.ensure_plugin_code_env( + "geocoder", FakeContext(), python_interpreter="PYTHON311" + ) + ) + assert result["status"] == "completed" + assert result["build"]["status"] == "completed" + assert plugin.settings.get_raw()["codeEnvName"] == result["code_env_name"] + assert plugin.created_interpreter == "PYTHON311" + + +def test_plugin_interpreter_schema_explains_dataiku_selection(): + registered = asyncio.run(tools.mcp.list_tools()) + tool = next(item for item in registered if item.name == "ensure_plugin_code_env") + interpreter = tool.parameters["properties"]["python_interpreter"] + assert "accepted versions" in interpreter["description"] + assert "server availability" in interpreter["description"] + assert interpreter["default"] is None + + +@pytest.mark.parametrize( + "message", + [ + "com.dataiku.dip.exceptions.CodedException: ERR_PLUGIN_WITHOUT_CODEENV: no specification", + "com.dataiku.dip.exceptions.CodedException: The plugin geocoder doesn't specify a code env, cannot create it", + ], +) +def test_store_without_code_env_is_not_required(message): + from dataikuapi.utils import DataikuException + + plugin, client, _ = _creation() + plugin.create_error = DataikuException(message) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == "not_required" + assert "codeEnvName" not in plugin.settings.get_raw() + + +@pytest.mark.parametrize( + "message", + [ + "permission denied", + "ERR_PLUGIN_WITHOUT_CODEENV_EXTRA", + "cannot create code env for another reason", + ], +) +def test_other_creation_errors_remain_errors(message): + from dataikuapi.utils import DataikuException + + plugin, client, _ = _creation() + plugin.create_error = DataikuException(message) + with _patch_client(client), pytest.raises(RuntimeError, match="refused") as excinfo: + _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert message in str(excinfo.value) + assert "codeEnvName" not in plugin.settings.get_raw() + + +def test_failed_build_is_preserved_beside_binding(): + plugin, client, _ = _creation( + states=[_code_env_done("plugin_geocoder_managed", error="pip install failed")] + ) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["build"]["status"] == "failed" + assert result["code_env_name"] == plugin.settings.get_raw()["codeEnvName"] + + +def test_bound_environment_does_not_create_or_claim_build_success(): + plugin, client = _installed(settings={"codeEnvName": "already-bound"}) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert not result["created"] + assert result["build"]["status"] == "unknown" + assert not plugin.futures + + +def _with_orphan(**kwargs): + return _installed( + code_envs=[ + {"envName": "plugin_geocoder_managed", "deploymentMode": "PLUGIN_MANAGED"} + ], + **kwargs, + ) + + +def test_orphan_is_listed_instead_of_guessed_or_duplicated(): + plugin, client = _with_orphan() + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == "recovery_required" + assert result["code_env_names"] == ["plugin_geocoder_managed"] + assert "code_env_name" in result["hint"] + assert not plugin.futures + assert "codeEnvName" not in plugin.settings.get_raw() + + +def test_named_orphan_is_bound_without_claiming_a_build(): + plugin, client = _with_orphan() + with _patch_client(client): + result = _load( + tools.ensure_plugin_code_env( + "geocoder", FakeContext(), code_env_name="plugin_geocoder_managed" + ) + ) + assert result["status"] == "completed" + assert result["created"] is False + assert result["build"]["status"] == "unknown" + assert plugin.settings.get_raw()["codeEnvName"] == "plugin_geocoder_managed" + assert not plugin.futures + + +@pytest.mark.parametrize( + "name", ["plugin_other_managed", "plugin_geocoder_managed_1", "my-env"] +) +def test_named_environment_must_be_an_existing_managed_env_of_the_plugin(name): + plugin, client = _with_orphan() + with _patch_client(client), pytest.raises(ValueError, match="Candidates"): + _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), code_env_name=name) + ) + assert "codeEnvName" not in plugin.settings.get_raw() + + +@pytest.mark.parametrize( + "kwargs", + [{"code_env_name": "plugin_geocoder_managed"}, {"python_interpreter": "PYTHON311"}], +) +def test_bound_plugin_refuses_a_different_environment_request(kwargs): + plugin, client = _installed(settings={"codeEnvName": "already-bound"}) + with _patch_client(client), pytest.raises(ValueError, match="already bound"): + _load(tools.ensure_plugin_code_env("geocoder", FakeContext(), **kwargs)) + assert plugin.settings.get_raw()["codeEnvName"] == "already-bound" + + +def test_timeout_is_resumed_by_exact_future_and_then_bound(monkeypatch): + plugin, client, future = _creation(states=[_alive(), _alive()]) + with _patch_client(client), _exhausted_clock() as mock_time: + mock_time.monotonic.side_effect = incrementing_monotonic() + result = _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), timeout_seconds=1) + ) + assert result["status"] == "still_running" + assert result["future_id"] == "F4" + assert "codeEnvName" not in plugin.settings.get_raw() + resumed = _build_future( + "F4", "plugin_geocoder_managed", [_code_env_done("plugin_geocoder_managed")] + ) + monkeypatch.setattr(tools, "DSSFuture", lambda client, future_id: resumed) + with _patch_client(client): + result = _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), future_id="F4") + ) + assert result["build"]["status"] == "completed" + assert result["created"] is True + assert plugin.settings.get_raw()["codeEnvName"] == "plugin_geocoder_managed" + + +@pytest.mark.parametrize( + "peeked", + [ + { + "payload": { + "action": "import_env", + "displayName": "Import env: plugin_other_managed", + } + }, + {"payload": {"action": "update_plugin", "displayName": "Update: geocoder"}}, + {"jobId": "wrong"}, + ], +) +def test_resuming_another_operations_future_neither_reads_nor_binds( + monkeypatch, peeked +): + plugin, client = _installed() + other = FakeFuture("wrong", [_code_env_done("plugin_other_managed")]) + other.peeked = peeked + monkeypatch.setattr(tools, "DSSFuture", lambda *args: other) + with ( + _patch_client(client), + pytest.raises(ValueError, match="not a code environment build"), + ): + _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), future_id="wrong") + ) + assert not getattr(other, "read", False) + assert "codeEnvName" not in plugin.settings.get_raw() + + +def test_forgotten_future_points_to_named_binding(monkeypatch): + from dataikuapi.utils import DataikuException + + _, client = _installed() + gone = FakeFuture("F4", [_alive()]) + + def forgotten(): + raise DataikuException( + "java.lang.IllegalArgumentException: No future by that id" + ) + + gone.peek_state = forgotten + monkeypatch.setattr(tools, "DSSFuture", lambda *args: gone) + with _patch_client(client), pytest.raises(ValueError, match="code_env_name"): + _load(tools.ensure_plugin_code_env("geocoder", FakeContext(), future_id="F4")) + + +def test_binding_failure_reports_the_reason_and_the_name_to_bind(monkeypatch): + from dataikuapi.utils import DataikuException + + plugin, client, _ = _creation() + + def fail_save(): + raise DataikuException( + "com.dataiku.dip.exceptions.UnauthorizedException: Action forbidden" + ) + + monkeypatch.setattr(plugin.settings, "save", fail_save) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == "binding_failed" + assert result["code_env_name"] == "plugin_geocoder_managed" + assert result["error"] == "Action forbidden" + + +def test_changed_binding_is_preserved(monkeypatch): + plugin, client, future = _creation( + states=[_alive(), _code_env_done("plugin_geocoder_managed")] + ) + original = future.get_state + + def switched(): + plugin.settings.raw["codeEnvName"] = "chosen-elsewhere" + return original() + + monkeypatch.setattr(future, "get_state", switched) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == "binding_changed" + assert result["bound_code_env_name"] == "chosen-elsewhere" + assert plugin.settings.raw["codeEnvName"] == "chosen-elsewhere" + + +@pytest.mark.parametrize("no_spec", [True, False]) +def test_code_env_contract_through_real_sdk_http_serialization(monkeypatch, no_spec): + """The real SDK drops errorCode; exercise that boundary instead of a fake exception.""" + import requests + from dataikuapi import DSSClient + + client = DSSClient("https://synthetic.example.com", "synthetic-key") + settings = {} + calls = [] + + def respond(method, url, **kwargs): + path = url.split("/dip/publicapi", 1)[1] + calls.append((method, path)) + status = 200 + if path == "/plugins/": + value = [_listing_entry("geocoder")] + elif path == "/admin/code-envs/": + value = [] + elif path.endswith("/settings"): + if method == "POST": + settings.update(json.loads(kwargs["data"])) + value = dict(settings) + elif path.endswith("/code-env/actions/create"): + body = json.loads(kwargs["data"]) + assert body["deploymentMode"] == "PLUGIN_MANAGED" + assert body["pythonInterpreter"] is None + if no_spec: + status = 400 + value = { + "errorType": "com.dataiku.dip.exceptions.CodedException", + "errorCode": "ERR_PLUGIN_WITHOUT_CODEENV", + "detailedMessage": "The plugin geocoder doesn't specify a code env, cannot create it", + } + else: + value = _code_env_done("plugin_geocoder_managed") + else: + pytest.fail(f"Unexpected SDK call {method} {path}") + response = requests.Response() + response.status_code = status + response._content = json.dumps(value).encode() + return response + + monkeypatch.setattr(client._session, "request", respond) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == ("not_required" if no_spec else "completed") + assert ("codeEnvName" in settings) is not no_spec + assert ("POST", "/plugins/geocoder/settings") in calls if not no_spec else True + + +def test_environment_name_without_build_report_is_not_verified_success(): + _, client, _ = _creation( + states=[{"hasResult": True, "result": {"envName": "plugin_geocoder_managed"}}] + ) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["build"]["status"] == "unknown" + + +@pytest.mark.parametrize( + "name", + [ + "plugin_geocoder-extra_managed", + "plugin_geocoder_managed_extra", + "plugin_other_managed", + ], +) +def test_similarly_named_environments_do_not_block_initial_creation(name): + _, client = _installed( + code_envs=[{"envName": name, "deploymentMode": "PLUGIN_MANAGED"}] + ) + _, client, _ = _creation(client) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["created"] is True + + +_GIT_URL = "https://git.example.com/team/plugin.git" + + +def _git_done(**kwargs): + return _done(pluginDesc={"desc": {"id": "geocoder"}}, **kwargs) + + +@pytest.mark.parametrize("wait", [False, True]) +def test_git_install_keeps_future_when_plugin_id_is_not_known(wait): + _, client = _absent(install=FakeFuture("G2", [_alive(), _alive()])) + with _patch_client(client), _exhausted_clock() as mock_time: + mock_time.monotonic.side_effect = incrementing_monotonic() + result = _load( + tools.install_plugin( + "git", + FakeContext(), + repository_url=_GIT_URL, + checkout="main", + wait_for_completion=wait, + timeout_seconds=1, + ) + ) + assert result["status"] == ("still_running" if wait else "started") + assert result["future_id"] == "G2" + assert "plugin_id" not in result + + +def test_git_install_reports_uncertain_verification_without_restarting(): + _, client = _absent(install=FakeFuture(None, [_git_done()])) + client.listing_after_change = [] + with _patch_client(client): + result = _load( + tools.install_plugin( + "git", + FakeContext(), + repository_url=_GIT_URL, + checkout="main", + ) + ) + assert result["status"] == "verification_required" + assert "list_plugins" in result["hint"] + + +@pytest.mark.parametrize("operation", ["install", "update"]) +def test_git_contract_with_real_sdk(monkeypatch, operation): + import requests + from dataikuapi import DSSClient + + monkeypatch.setattr(request, "is_http_request", lambda: True) + client = DSSClient("https://synthetic.example.com", "synthetic-key") + calls = [] + endpoint = ( + "/plugins/actions/installFromGit" + if operation == "install" + else "/plugins/geocoder/actions/updateFromGit" + ) + checkout = "v1.0" if operation == "install" else _GIT_COMMIT + + def respond(method, url, **kwargs): + path = url.split("/dip/publicapi", 1)[1] + calls.append((method, path)) + if path == endpoint: + assert json.loads(kwargs["data"]) == { + "gitRepositoryUrl": _GIT_URL, + "gitCheckout": checkout, + "gitSubpath": "plugin", + } + value = {"jobId": "G4", **_alive()} + elif path == "/futures/G4": + value = _git_done(needsReload=True) + value["result"]["settings"] = {"secret": "do-not-return"} + elif path == "/plugins/": + value = [_listing_entry("geocoder")] + else: + pytest.fail(f"Unexpected SDK call {method} {path}") + response = requests.Response() + response.status_code = 200 + response._content = json.dumps(value).encode() + return response + + monkeypatch.setattr(client._session, "request", respond) + with _patch_client(client): + result = _load( + getattr(tools, f"{operation}_plugin")( + "git", + FakeContext(), + repository_url=_GIT_URL, + checkout=checkout, + **({"plugin_id": "geocoder"} if operation == "update" else {}), + subpath="plugin", + ) + ) + assert result["status"] == "completed" + assert result["plugin_id"] == result["plugin"]["id"] == "geocoder" + assert result["needs_reload"] is True + if operation == "install": + assert result["code_env"] == {"status": "unknown"} + assert "do-not-return" not in json.dumps(result) + assert calls == ([("GET", "/plugins/")] if operation == "update" else []) + [ + ("POST", endpoint), + ("GET", "/futures/G4"), + ("GET", "/plugins/"), + ] + + +_GIT_COMMIT = "a1" * 20 + + +@pytest.mark.parametrize( + "http,rebuild,checkout,subpath", + [ + (True, True, "release/1.0", "plugins/geocoder"), + (False, False, "v2.0", None), + (False, False, _GIT_COMMIT, None), + ], +) +def test_git_update_uses_chosen_revision_and_path( + monkeypatch, http, rebuild, checkout, subpath +): + monkeypatch.setattr(request, "is_http_request", lambda: http) + plugin, client = _installed( + settings={"codeEnvName": "plugin_geocoder_managed"}, + futures={ + "update": FakeFuture("GU1", [_alive(), _git_done(needsReload=True)]), + "update_code_env": FakeFuture( + None, [_code_env_done("plugin_geocoder_managed")] + ), + }, + listing_after_change=[_listing_entry("geocoder", "2.0.0")], + ) + with _patch_client(client): + result = _load( + tools.update_plugin( + "git", + FakeContext(), + plugin_id="geocoder", + repository_url=_GIT_URL, + checkout=checkout, + subpath=subpath, + rebuild_code_env=rebuild, + ) + ) + assert plugin.git_update_args == (_GIT_URL, checkout, subpath) + assert result["plugin"]["version"] == "2.0.0" + assert result["needs_reload"] is True + assert result["status"] == "completed" + assert ("code_env_rebuild" in result) is rebuild + if rebuild: + assert result["code_env_rebuild"]["status"] == "completed" + + +@pytest.mark.parametrize("wait", [False, True]) +def test_git_update_preserves_future_and_defers_rebuild(wait): + _, client = _installed(futures={"update": FakeFuture("GU2", [_alive(), _alive()])}) + with _patch_client(client), _exhausted_clock() as mock_time: + mock_time.monotonic.side_effect = incrementing_monotonic() + result = _load( + tools.update_plugin( + "git", + FakeContext(), + plugin_id="geocoder", + repository_url=_GIT_URL, + checkout=_GIT_COMMIT, + wait_for_completion=wait, + rebuild_code_env=wait, + timeout_seconds=1, + ) + ) + assert result["future_id"] == "GU2" + assert result["status"] == ("still_running" if wait else "started") + if wait: + assert result["code_env_rebuild"]["status"] == "not_started" + + +@pytest.mark.parametrize("actual_id", [None, "other-plugin"]) +def test_git_update_unexpected_result_requires_verification_and_skips_rebuild( + actual_id, +): + _, client = _installed( + futures={ + "update": FakeFuture(None, [_done(pluginDesc={"desc": {"id": actual_id}})]) + } + ) + with _patch_client(client): + result = _load( + tools.update_plugin( + "git", + FakeContext(), + plugin_id="geocoder", + repository_url=_GIT_URL, + checkout=_GIT_COMMIT, + rebuild_code_env=True, + ) + ) + assert result["status"] == "verification_required" + assert "code_env_rebuild" not in result + assert result.get("reported_plugin_id") == actual_id + + +def test_git_update_refuses_missing_target_before_mutation(): + _, client = _absent() + with _patch_client(client), pytest.raises(ValueError, match="not installed"): + _load( + tools.update_plugin( + "git", + FakeContext(), + plugin_id="geocoder", + repository_url=_GIT_URL, + checkout=_GIT_COMMIT, + ) + ) + + +@pytest.mark.parametrize( + "operation,stage", [("install", "start"), ("install", "poll"), ("update", "result")] +) +def test_git_access_failure_provides_help_and_recovery(monkeypatch, operation, stage): + from dataikuapi.utils import DataikuException + + plugin, client = _installed() if operation == "update" else _absent() + future = FakeFuture("G-auth", [_alive()]) + futures = plugin.futures if operation == "update" else client.futures + futures[operation] = future + secret = "synthetic-token" + message = f"Authentication failed https://user:{secret}@git.example.com/plugin" + + def fail(*args, **kwargs): + raise DataikuException(message) + + if stage == "start": + monkeypatch.setattr(client, "install_plugin_from_git", fail) + elif stage == "poll": + monkeypatch.setattr(future, "get_state", fail) + else: + futures[operation] = FakeFuture( + "G-auth", [_done(success=False, installationError={"message": message})] + ) + with _patch_client(client), pytest.raises(RuntimeError) as error: + _load( + getattr(tools, f"{operation}_plugin")( + "git", + FakeContext(), + repository_url=_GIT_URL, + checkout="main", + **({"plugin_id": "geocoder"} if operation == "update" else {}), + ) + ) + assert "Dataiku's Git settings" in str(error.value) + assert "Credentials on the Headless machine do not apply" in str(error.value) + assert secret not in str(error.value) + if stage != "start": + assert "G-auth" in str(error.value) + + +def test_git_non_auth_failure_does_not_claim_missing_credentials(): + _, client = _absent( + install=FakeFuture( + None, + [ + _done( + success=False, + installationError={"message": "Remote branch does not exist"}, + ) + ], + ) + ) + with _patch_client(client), pytest.raises(RuntimeError) as error: + _load( + tools.install_plugin( + "git", FakeContext(), repository_url=_GIT_URL, checkout="missing" + ) + ) + assert "could not authenticate" not in str(error.value) + assert "checkout" in str(error.value) + assert "list_plugins" in str(error.value) + + +@pytest.mark.parametrize( + "operation,source,overrides", + [ + ("install", "git", {"repository_url": None}), + ("update", "git", {"checkout": " "}), + ("update", "git", {"plugin_id": None}), + ("install", "git", {"plugin_id": "geocoder"}), + ("update", "git", {"local_path": "/tmp/plugin"}), + ("install", "git", {"subpath": "../plugin"}), + ("update", "git", {"subpath": "/plugin"}), + ( + "install", + "git", + {"repository_url": "https://user:synthetic-token@git.example.com/plugin"}, + ), + ( + "update", + "git", + {"repository_url": "https://git.example.com/plugin?token=synthetic-token"}, + ), + ("install", "git", {"repository_url": "file:///server/plugin"}), + ("install", "store", {}), + ("update", "local_path", {}), + ], +) +def test_git_source_validation_rejects_invalid_arguments_before_io( + operation, source, overrides +): + arguments = dict(repository_url=_GIT_URL, checkout="main") + if operation == "update": + arguments["plugin_id"] = "geocoder" + arguments.update(overrides) + with pytest.raises(ValueError) as error: + _load(getattr(tools, f"{operation}_plugin")(source, FakeContext(), **arguments)) + assert "synthetic-token" not in str(error.value) + + +@pytest.mark.parametrize( + "url", + [ + "git@git.example.com:team/plugin.git", + "ssh://git@git.example.com/team/plugin.git", + ], +) +def test_git_ssh_remote_forms_are_accepted(url): + assert tools._require_git_repository_url(url) == url + + +@pytest.mark.parametrize("after_save", [False, True]) +def test_binding_transport_timeout_keeps_build_and_supports_retry( + monkeypatch, after_save +): + from requests.exceptions import Timeout + + plugin, client, _ = _creation() + original = plugin.settings.save + + def save(): + if after_save: + original() + else: + plugin.settings.raw.pop("codeEnvName", None) + raise Timeout("sensitive transport detail") + + monkeypatch.setattr(plugin.settings, "save", save) + with _patch_client(client): + result = _load(tools.ensure_plugin_code_env("geocoder", FakeContext())) + assert result["status"] == "binding_unknown" + assert result["build"]["status"] == "completed" + assert result["code_env_name"] == "plugin_geocoder_managed" + assert "sensitive" not in json.dumps(result) + assert bool(plugin.settings.raw.get("codeEnvName")) is after_save + client.code_envs = [ + {"deploymentMode": "PLUGIN_MANAGED", "envName": result["code_env_name"]} + ] + monkeypatch.setattr(plugin.settings, "save", original) + with _patch_client(client): + result = _load( + tools.ensure_plugin_code_env( + "geocoder", FakeContext(), code_env_name=result["code_env_name"] + ) + ) + assert result["status"] == "completed" + + +def test_automation_build_resumes_only_for_retained_owner(monkeypatch): + plugin, client, _ = _creation(states=[_alive(), _alive()]) + with _patch_client(client), _exhausted_clock() as clock: + clock.monotonic.side_effect = incrementing_monotonic() + _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), timeout_seconds=1) + ) + resumed = _build_future( + "F4", "plugin_geocoder_managed", [_code_env_done("plugin_geocoder_managed")] + ) + resumed.peeked["payload"]["displayName"] = "Import env" + monkeypatch.setattr(tools, "DSSFuture", lambda *args: resumed) + with _patch_client(client): + result = _load( + tools.ensure_plugin_code_env("geocoder", FakeContext(), future_id="F4") + ) + assert result["status"] == "completed" + assert not tools._BUILD_OWNERS + + +@pytest.mark.parametrize("scope", ["plugin", "instance", "caller", "restart"]) +def test_automation_build_does_not_consume_an_unowned_future(monkeypatch, scope): + _, client = _installed() + key = tools._build_owner_key(client, "F4") + tools._BUILD_OWNERS[key] = "other" if scope == "plugin" else "geocoder" + if scope == "instance": + client.host = "https://other.example.com" + elif scope == "caller": + monkeypatch.setattr(request, "get_request_owner", lambda: ("oidc", "other")) + elif scope == "restart": + tools._BUILD_OWNERS.clear() + future = _build_future( + "F4", "plugin_geocoder_managed", [_code_env_done("plugin_geocoder_managed")] + ) + future.peeked["payload"]["displayName"] = "Import env" + monkeypatch.setattr(tools, "DSSFuture", lambda *args: future) + with ( + _patch_client(client), + pytest.raises(ValueError, match="not a code environment build"), + ): + _load(tools.ensure_plugin_code_env("geocoder", FakeContext(), future_id="F4")) + assert not getattr(future, "read", False) diff --git a/tests/test_release.py b/tests/test_release.py index 73eb8854..cdfdd9ba 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -32,9 +32,9 @@ def release_tree(tmp_path): for name in ("pyproject.toml", "CHANGELOG.md"): shutil.copy(ROOT / name, tmp_path / name) - for path in ROOT.glob(".*-plugin/*.json"): + for path in [*ROOT.glob(".*-plugin/*.json"), *ROOT.glob(".github/plugin/*.json")]: target = tmp_path / path.relative_to(ROOT) - target.parent.mkdir(exist_ok=True) + target.parent.mkdir(parents=True, exist_ok=True) shutil.copy(path, target) return tmp_path @@ -46,9 +46,18 @@ def test_current_release_and_notes(release_tree): assert notes.count("\n## ") == 0 -@pytest.mark.parametrize("name", ["claude", "codex", "cortex", "cursor"]) -def test_reject_manifest_version_drift(release_tree, name): - path = release_tree / f".{name}-plugin/plugin.json" +@pytest.mark.parametrize( + "directory", + [ + ".claude-plugin", + ".codex-plugin", + ".cortex-plugin", + ".cursor-plugin", + ".github/plugin", + ], +) +def test_reject_manifest_version_drift(release_tree, directory): + path = release_tree / directory / "plugin.json" manifest = json.loads(path.read_text()) manifest["version"] = "0.0.1" path.write_text(json.dumps(manifest)) @@ -56,15 +65,17 @@ def test_reject_manifest_version_drift(release_tree, name): HELPERS["validate"](release_tree) -def test_reject_missing_bump_configuration(release_tree): +@pytest.mark.parametrize("directory", [".cursor-plugin", ".github/plugin"]) +def test_reject_missing_bump_configuration(release_tree, directory): path = release_tree / "pyproject.toml" - path.write_text(path.read_text().replace(' ".cursor-plugin/plugin.json",\n', "")) + path.write_text(path.read_text().replace(f' "{directory}/plugin.json",\n', "")) with pytest.raises(ValueError, match="version_files"): HELPERS["validate"](release_tree) -def test_reject_marketplace_pin(release_tree): - path = release_tree / ".claude-plugin/marketplace.json" +@pytest.mark.parametrize("directory", [".claude-plugin", ".github/plugin"]) +def test_reject_marketplace_pin(release_tree, directory): + path = release_tree / directory / "marketplace.json" data = json.loads(path.read_text()) data["plugins"][0]["version"] = "0.0.1" path.write_text(json.dumps(data)) @@ -72,6 +83,12 @@ def test_reject_marketplace_pin(release_tree): HELPERS["validate"](release_tree) +def test_reject_missing_github_manifest(release_tree): + (release_tree / ".github/plugin/plugin.json").unlink() + with pytest.raises(ValueError, match="Missing release manifest"): + HELPERS["validate"](release_tree) + + def test_reject_missing_changelog(release_tree): with pytest.raises(ValueError, match="No changelog section"): HELPERS["release_notes"](release_tree, "999.0.0") diff --git a/tests/test_setup_server.py b/tests/test_setup_server.py index 74601c1b..af69b8c7 100644 --- a/tests/test_setup_server.py +++ b/tests/test_setup_server.py @@ -12,14 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen import pytest +from requests.exceptions import SSLError from dataiku_mcp import setup_server -from dataiku_mcp.setup_server import _page, _validate_form +from dataiku_mcp.config import stdio +from dataiku_mcp.config.models import DSSInstance, StdioDSSInstanceConfig +from dataiku_mcp.setup_server import _test_connection, _validate_form NORMALIZED_URLS = ( ( @@ -90,36 +95,11 @@ def test_validate_form_rejects_malformed_or_unsafe_urls(url): _validate_form(_form(url)) -def test_setup_page_explains_instance_url(): - page = _page() - - assert "Enter the URL of your Dataiku instance." in page - - -@pytest.mark.parametrize( - "instance_type", ["design", "automation", "deployer", "govern", "agent-management"] -) -def test_setup_form_and_page_preserve_instance_type(instance_type): - values = _validate_form( - {**_form("https://example.com"), "instance_type": [instance_type]} - ) - assert values["instance_type"] == instance_type - for page in ( - _page(values=values, connection_validated=True), - _page(values=values, error="Connection failed"), - ): - assert f'' in page - assert '