diff --git a/CODING_STANDARDS_AND_STRUCTURE.md b/CODING_STANDARDS_AND_STRUCTURE.md index 3e25be0b..201b56ef 100644 --- a/CODING_STANDARDS_AND_STRUCTURE.md +++ b/CODING_STANDARDS_AND_STRUCTURE.md @@ -103,9 +103,9 @@ export DKU_API_KEY="your-api-key" - Give a fixed-value parameter a `Literal` so its allowed values ride in the schema, and drop the `require_allowed_value` check it replaces. Keep the check and describe the values in prose instead when it normalizes input (for example `.upper()`), since a `Literal` would reject values that work today, or when the value set is large enough that listing it in every catalog send costs more than a description. ## 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. +- 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, and `create_python_scenario` and `set_python_scenario_script` are the narrow Python-script scenario exceptions; 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`, `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`). +- 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), deterministic container execution placement for an existing object (`set_container_exec_config`), and Python-script scenario authoring (`create_python_scenario`, which creates an inactive scenario without triggers, and `set_python_scenario_script`), because Cobuild creates only step-based scenarios and cannot edit a scenario's script. ## Fixed Tool Surface diff --git a/README.md b/README.md index 3622fb27..3506e40f 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ npx skills add dataiku/dataiku-headless Dataiku Headless is an MCP server and agent skill library for operating Dataiku from an AI agent. Connect it to a Dataiku instance to inspect projects, gather context, and use Cobuild—Dataiku's agent for building data pipelines, analytics, machine learning models, multi-agent workflows, applications, and automation pipelines. -Cobuild runs as a retained conversation through MCP tools. This repository intentionally keeps its own tool surface small: inspection tools, three deterministic executions of existing assets (`build_datasets`, `run_recipe`, and `run_scenario`), narrow configuration actions such as selecting which container an existing object runs in, and a few bootstrap actions that Cobuild cannot perform, such as creating a project or uploading a local file. +Cobuild runs as a retained conversation through MCP tools. This repository intentionally keeps its own tool surface small: inspection tools, three deterministic executions of existing assets (`build_datasets`, `run_recipe`, and `run_scenario`), narrow configuration actions such as selecting which container an existing object runs in, Python-script scenario authoring that Cobuild does not support, and a few bootstrap actions that Cobuild cannot perform, such as creating a project or uploading a local file. ## Capability reference @@ -296,7 +296,7 @@ The Dataiku Headless marketplace plugin uses stdio transport. For customer-manag │ │ ├── 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 +│ │ ├── scenarios.py # Scenario inspection, runs, and Python-script scenarios │ │ ├── semantic_models.py # Semantic model inspection tools │ │ ├── groups.py # Instance group administration tools │ │ ├── licensing.py # Instance licensing status inspection tool diff --git a/dataiku_mcp/tools/scenarios.py b/dataiku_mcp/tools/scenarios.py index 171bfd61..84954fdb 100644 --- a/dataiku_mcp/tools/scenarios.py +++ b/dataiku_mcp/tools/scenarios.py @@ -12,17 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Scenario inspection and execution tools for Dataiku.""" +"""Scenario inspection, execution, and Python-script scenario authoring tools.""" import asyncio import time from typing import Annotated +from dataikuapi.utils import DataikuException from fastmcp import Context from pydantic import Field from ..instance_policy import ( ADMIN_POLICY, + PROJECT_EDIT_POLICY, PROJECT_EXECUTION_POLICY, PROJECT_READ_POLICY, ) @@ -42,6 +44,42 @@ SCENARIO_RUN_ID_RESOLVE_BUDGET_SECONDS = 4 MAX_SCENARIO_WAIT_SECONDS = 3600 +PythonScript = Annotated[ + str, Field(description="Complete Python source; replaces the whole script.") +] + + +def _python_scenario_settings(scenario, scenario_id: str): + """Read one scenario's settings, refusing a scenario without a Python script.""" + settings = scenario.get_settings() + scenario_type = settings.get_raw().get("type") + if scenario_type != "custom_python": + raise ValueError( + f"Scenario '{scenario_id}' is a '{scenario_type}' scenario; only " + "Python-script (custom_python) scenarios have a script" + ) + return settings + + +def _save_script(settings, script: str) -> None: + # The SDK saves the definition before the script. That definition save runs + # Dataiku's code-permission and license checks, which the script endpoint + # alone does not, so a refused caller never reaches the script write. + settings.code = script + settings.save() + + +def _python_scenario_summary(project_key: str, settings) -> dict: + raw = settings.get_raw() + return { + "project_key": project_key, + "scenario_id": raw.get("id"), + "name": raw.get("name"), + "type": raw.get("type"), + "active": raw.get("active", False), + "trigger_count": len(raw.get("triggers") or []), + } + async def _resolve_scenario_run_id(trigger_fire, budget_seconds: int): """Resolve the scenario run id after a trigger fires.""" @@ -148,21 +186,149 @@ async def get_scenario_settings( scenario_id: str, ctx: Context, ) -> str: - """Read what a scenario does, on what trigger, and who it reports to.""" + """Read a scenario's steps or Python script, its triggers, and who it reports to.""" project_key = _require_non_empty_string(project_key, "project_key") scenario_id = _require_non_empty_string(scenario_id, "scenario_id") await ctx.info(f"Fetching settings for scenario {scenario_id} in {project_key}...") - raw = await run_blocking( - lambda: ( - get_dss_client() - .get_project(project_key) - .get_scenario(scenario_id) - .get_settings() - .get_raw() + def _run(): + scenario = get_dss_client().get_project(project_key).get_scenario(scenario_id) + settings = scenario.get_settings() + raw = settings.get_raw() + if raw.get("type") == "custom_python": + return {**raw, "script": settings.code} + return raw + + return compact_json(await run_blocking(_run)) + + +@mcp.tool( + meta=PROJECT_EDIT_POLICY, + title="Create Python Scenario", + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def create_python_scenario( + project_key: str, + name: str, + script: PythonScript, + ctx: Context, +) -> str: + """Create a scenario defined by a Python script, inactive and without triggers.""" + project_key = _require_non_empty_string(project_key, "project_key") + name = _require_non_empty_string(name, "name") + # Validate only: the script is saved exactly as given. + _require_non_empty_string(script, "script") + await ctx.info(f"Creating Python scenario '{name}' in {project_key}...") + + def _run(): + project = get_dss_client().get_project(project_key) + # Dataiku accepts duplicate names, so a retried call would otherwise + # leave a second scenario holding Dataiku's sample script. + existing = next( + (item for item in project.list_scenarios() if item.get("name") == name), + None, ) - ) - return compact_json(raw) + if existing is not None: + raise ValueError( + f"Project '{project_key}' already has a scenario named '{name}' " + f"(id '{existing.get('id')}'). Choose another name, or use " + "set_python_scenario_script to change that scenario's script" + ) + try: + scenario = project.create_scenario( + name, + "custom_python", + definition={ + "params": {}, + "active": False, + "triggers": [], + "reporters": [], + }, + ) + except DataikuException: + # Dataiku answered and refused, so nothing was created. + raise + except Exception as exc: + raise RuntimeError( + f"Dataiku did not confirm creating scenario '{name}'. Check " + "list_scenarios before retrying because the request may have " + "reached Dataiku." + ) from exc + + # Dataiku creates the scenario with a sample script, so until the real + # script is confirmed the result must say the scenario is not ready. + error = None + try: + _save_script(_python_scenario_settings(scenario, scenario.id), script) + saved = _python_scenario_settings(scenario, scenario.id) + if saved.code == script: + return { + "status": "python_scenario_created", + **_python_scenario_summary(project_key, saved), + } + except Exception as exc: + error = exc + return { + "status": "python_scenario_script_unconfirmed", + "project_key": project_key, + "scenario_id": scenario.id, + "name": name, + **( + {"error_type": type(error).__name__, "error": _safe_error_text(error)} + if error is not None + else {} + ), + "hint": ( + "The scenario exists, inactive and without triggers, but its script " + "was not confirmed and may still be Dataiku's sample script. Do not " + "run it. Call set_python_scenario_script with this scenario_id and " + "the same script; do not create the scenario again." + ), + } + + return compact_json(await run_blocking(_run)) + + +@mcp.tool( + meta=PROJECT_EDIT_POLICY, + title="Set Python Scenario Script", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, +) +async def set_python_scenario_script( + project_key: str, + scenario_id: str, + script: PythonScript, + ctx: Context, +) -> str: + """Replace the script of an existing Python-script scenario, keeping its settings.""" + project_key = _require_non_empty_string(project_key, "project_key") + scenario_id = _require_non_empty_string(scenario_id, "scenario_id") + # Validate only: the script is saved exactly as given. + _require_non_empty_string(script, "script") + await ctx.info(f"Saving the script of scenario {scenario_id} in {project_key}...") + + def _run(): + scenario = get_dss_client().get_project(project_key).get_scenario(scenario_id) + _save_script(_python_scenario_settings(scenario, scenario_id), script) + saved = _python_scenario_settings(scenario, scenario_id) + if saved.code != script: + raise RuntimeError( + f"Dataiku did not confirm the new script of scenario '{scenario_id}'. " + "Inspect it with get_scenario_settings; repeating this call is safe." + ) + return _python_scenario_summary(project_key, saved) + + return compact_json(await run_blocking(_run)) @mcp.tool( diff --git a/docs/capabilities.md b/docs/capabilities.md index e3eeef8c..76148798 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -7,16 +7,19 @@ its object-specific references as directed. ## The rule -**Flow and analytic logic is always built by Cobuild.** Headless has no tool to create +**Flow and analytic logic is built by Cobuild, with one exception.** Headless has no tool to create or change recipe logic, code, inputs, or outputs, or to create or modify the definition of an ML analysis, dashboard, insight, agent, agent tool, scenario, webapp, wiki article, data quality rule, knowledge bank, semantic model, or evaluation store. The container execution override below changes only which container an existing object runs in. +Python-script scenarios are that exception: Cobuild creates only step-based scenarios +and cannot edit a scenario's script, so Headless creates them and replaces their script +directly. Headless writes Dataiku objects directly in four cases: **bootstrap** (get a project or local content onto the instance so Cobuild has something to work with), **project configuration**, **cross-project**, and **instance-level administration**. It also -re-runs assets that already exist. Some bootstrap writes do land inside a project — +authors Python-script scenarios, the exception above, and re-runs assets that already exist. Some bootstrap writes do land inside a project — see the table below. Local profile actions are listed separately because they only change which Dataiku instance the local client targets. @@ -37,8 +40,9 @@ Dataiku permissions and API availability still apply to permitted calls. 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`. +`set_container_exec_config`, `create_python_scenario`, `set_python_scenario_script`, +`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. @@ -56,13 +60,13 @@ test. Design and Automation require a successful DSS connection test in local se ## Surface -**131 tools** · 93 read · 24 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**133 tools** · 93 read · 26 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 24 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 26 | Bootstrap, project configuration, cross-project, admin | | Cobuild conversation | 6 | All flow and analytic building | | 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` | @@ -74,9 +78,10 @@ bucket here. ## Built by Cobuild — limited direct exceptions -Three partial exceptions: Headless can create a dataset from a local file, create an -empty managed folder, and select container execution for an existing object (see -*Handled directly by Headless*). It cannot build or change analytic logic here. +Four partial exceptions: Headless can create a dataset from a local file, create an +empty managed folder, select container execution for an existing object, and create a +Python-script scenario or replace its script (see *Handled directly by Headless*). It +cannot build or change other analytic logic here. | Area | Inspect | Direct action | |---|---|---| @@ -87,7 +92,7 @@ empty managed folder, and select container execution for an existing object (see | Saved models | `list_saved_models`, `list_saved_model_versions`, `get_saved_model_version_details` | — | | Agents | `list_agents`, `get_agent_settings`, `list_agent_versions`, `list_agent_tools`, `get_agent_tool_settings` | `set_container_exec_config` (agent tools) | | Agent reviews | `list_agent_reviews`, `get_agent_review`, `list_agent_review_tests`, `list_agent_review_runs`, `get_agent_review_run_results` | — | -| Scenarios | `list_scenarios`, `get_scenario_settings`, `get_scenario_run_history`, `list_messaging_channels` | `run_scenario` | +| Scenarios | `list_scenarios`, `get_scenario_settings`, `get_scenario_run_history`, `list_messaging_channels` | `create_python_scenario`, `set_python_scenario_script` (Python-script scenarios), `run_scenario` | | Dashboards & insights | `list_dashboards`, `get_dashboard_settings`, `list_insights`, `get_insight_settings` | — | | Webapps | `list_webapps`, `get_webapp_settings`, `get_webapp_state` | `set_container_exec_config` | | Wikis | `list_wiki_articles`, `get_wiki_article` | — | @@ -115,6 +120,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. |---|---|---|---| | Projects | `count_projects`, `list_projects`, `get_project_metadata`, `get_project_variables`, `get_project_settings` | `create_project`, `set_project_variables`, `update_project_settings` | Bootstrap and project configuration — direct writes are **in-project** | | Container execution placement | `get_recipe_settings`, `get_ml_analysis_settings`, `get_webapp_settings`, `get_knowledge_bank_settings`, `get_agent_tool_settings`, `list_container_exec_configs` | `set_container_exec_config` | Project configuration, **in-project** — sets which container a recipe, ML task, WebApp backend, Knowledge Bank, or agent tool runs in. Saved-model retrains use their training recipe's `params.containerSelection`. | +| Python-script scenarios | `list_scenarios`, `get_scenario_settings` (includes the script) | `create_python_scenario`, `set_python_scenario_script` | Project content, **in-project** — creates a Python-script (custom_python) scenario, inactive and without triggers, or replaces an existing one's whole script. Triggers, reporters, and activation stay with Cobuild. | | Uploaded Files datasets | — | `create_upload_dataset` | Bootstrap, **in-project** — supplied rows in HTTP; local files in stdio only | | Managed folders | `list_managed_folders`, `get_managed_folder_info`, `get_managed_folder_contents` | `create_managed_folder`, `upload_file_to_managed_folder` | Bootstrap, **in-project** — local-file upload is stdio only | | 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 | @@ -128,8 +134,9 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | -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. +Except for Python-script scenarios, 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 diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 1353336c..4965f70a 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -30,9 +30,9 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t 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 +Python-script scenario authoring, 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. @@ -76,7 +76,7 @@ unsupported calls fail before execution. | Inspect Agent Reviews | `./references/agent-reviews.md` | | Inspect dashboards | `./references/dashboards.md` | | Inspect insights | `./references/insights.md` | -| Inspect scenarios and automation history, or run an existing scenario | `./references/scenarios.md` | +| Inspect scenarios and automation history, run an existing scenario, or create a Python-script scenario or edit its script | `./references/scenarios.md` | | Inspect semantic models | `./references/semantic-models.md` | | Inspect WebApps and backend state | `./references/webapps.md` | | Inspect wiki hierarchy or article content | `./references/wikis.md` | diff --git a/skills/dataiku-headless/references/scenarios.md b/skills/dataiku-headless/references/scenarios.md index 2dacac1a..f2e3a442 100644 --- a/skills/dataiku-headless/references/scenarios.md +++ b/skills/dataiku-headless/references/scenarios.md @@ -1,6 +1,6 @@ --- name: scenarios -description: Inspect existing Dataiku scenarios, run them manually when requested, and review their history. Use when reviewing automation, executing an existing scenario, or planning scenario changes through Cobuild. +description: Inspect existing Dataiku scenarios, run them manually when requested, and review their history. Use when reviewing automation, executing an existing scenario, creating a Python-script scenario or editing its script, or planning other scenario changes through Cobuild. --- # Scenarios @@ -11,13 +11,15 @@ Use this guide to understand existing project automation and plan grounded scena A scenario is a Dataiku automation object. It combines ordered steps, automatic triggers, and completion reporters. +A scenario has one of two types. A **step-based** (`step_based`) scenario runs an ordered list of steps. A **Python-script** (`custom_python`) scenario runs one Python script that drives the run through the `dataiku.scenario` API; it has triggers and reporters but no steps. + - **Steps** perform work such as building data, training models, moving or exporting data, running custom work, or sending notifications. - **Triggers** start a scenario when a schedule or configured project event occurs. - **Reporters** notify people when a run completes. The `active` setting controls whether automatic triggers can start runs; it does not remove the scenario. Run history records outcomes and timing, providing evidence about reliability and recurring failures. -Scenarios can have broad operational impact. Creation, edits, activation, and deletion route through Cobuild. `run_scenario` is the fixed direct exception for executing an existing scenario. +Scenarios can have broad operational impact. Creation, edits, activation, and deletion route through Cobuild. `run_scenario` is the fixed direct exception for executing an existing scenario. Cobuild creates only step-based scenarios and cannot edit a scenario's script, so `create_python_scenario` and `set_python_scenario_script` are the fixed direct exceptions for Python-script scenarios. | Component | Concept | | --- | --- | @@ -37,7 +39,22 @@ When describing automation to Cobuild, state the intended trigger, work sequence 4. When the user explicitly requests a manual run and no run is active, use `run_scenario`. 5. Use `list_messaging_channels` only when reporter configuration is relevant. 6. Inspect the project objects a scenario operates on when a requested change affects them. -7. Route scenario creation, edits, activation, and deletion through `./cobuild.md`. +7. To create a Python-script scenario or change its script, follow [Python-Script Scenarios](#python-script-scenarios). +8. Route all other scenario creation, edits, activation, and deletion through `./cobuild.md`. + +## Python-Script Scenarios + +`create_python_scenario` and `set_python_scenario_script` require `design`; they are unavailable on Automation. + +Create a Python-script scenario only when the user explicitly asks for one or for scripted automation. Otherwise describe a step-based scenario to Cobuild. + +1. Use `list_scenarios` to choose a name no scenario uses. `create_python_scenario` refuses a name already in use and returns that scenario's id. +2. Write the complete script. Use the `dataiku.scenario` API for builds and other work, and limit side effects to what the user asked for. +3. Call `create_python_scenario`. It creates the scenario inactive and without triggers, saves the script, and confirms it by reading it back. Keep the returned `scenario_id`, which Dataiku derives from the name. +4. If the status is `python_scenario_script_unconfirmed`, the scenario exists but may still hold Dataiku's sample script. Do not run it. Call `set_python_scenario_script` with the returned `scenario_id` and the same script; do not create the scenario again. +5. To change an existing script, read it with `get_scenario_settings`, which returns the `script` of a Python-script scenario. Then call `set_python_scenario_script` with the complete new script. It replaces the whole script and keeps the name, triggers, reporters, activation, and other settings. +6. Confirm the saved script with `get_scenario_settings`. Run the scenario only when the user asks, with `run_scenario`, and check the outcome with `get_scenario_run_history`. +7. Route trigger, reporter, and activation changes through `./cobuild.md`, as for any scenario. These tools do not change a scenario's Python code environment; a created scenario uses the inherited default. ## Scenario Run Identification @@ -61,6 +78,8 @@ Keep the ids returned by `run_scenario` so later turns can follow the same execu - `get_scenario_settings` - `get_scenario_run_history` - `run_scenario` +- `create_python_scenario` +- `set_python_scenario_script` - `list_messaging_channels` ## Safety Rules @@ -72,4 +91,7 @@ Keep the ids returned by `run_scenario` so later turns can follow the same execu - A bounded wait returning `scenario_run_still_running` is not failure. Keep polling the same run. - Treat scenarios with expensive builds, training, exports, or notifications as consequential automation. - Preserve existing triggers, reporters, and delayed-trigger behavior unless the user requests a change. -- Keep this skill focused on inspection, concepts, and Cobuild grounding. Do not document direct scenario mutation workflows here. +- A Python-script scenario runs arbitrary Python as the scenario's run-as user. Running it can build, change, or delete data and call external services. Inspect the script before every run. +- Dataiku requires permission to write code and a license that allows Python scenarios. A refused save is reported unchanged; do not work around it. +- Saving a script also saves the scenario definition read just before. An edit someone else makes in between is lost, so avoid it while another person edits that scenario. +- Keep this skill focused on inspection, concepts, and Cobuild grounding. Do not document direct scenario mutation workflows beyond Python-script creation and script replacement. diff --git a/tests/test_instance_policy.py b/tests/test_instance_policy.py index 0e549048..3f522a0b 100644 --- a/tests/test_instance_policy.py +++ b/tests/test_instance_policy.py @@ -36,6 +36,8 @@ "update_project_settings", "set_project_variables", "set_container_exec_config", + "create_python_scenario", + "set_python_scenario_script", "create_upload_dataset", "create_managed_folder", "upload_file_to_managed_folder", diff --git a/tests/test_python_scenarios.py b/tests/test_python_scenarios.py new file mode 100644 index 00000000..a1967e32 --- /dev/null +++ b/tests/test_python_scenarios.py @@ -0,0 +1,404 @@ +# 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. + +"""Unit tests for Python-script scenario creation, script edits, and inspection.""" + +import asyncio +import copy +import json + +import pytest +import requests +from dataikuapi.utils import DataikuException +from fastmcp import Client + +from dataiku_mcp import mcp +from dataiku_mcp.config import stdio +from dataiku_mcp.config.models import DSSInstance +from dataiku_mcp.tools import scenarios + +from tests.utils.fakes import FakeContext + +SAMPLE_SCRIPT = "# Dataiku sample script\nscenario.build_dataset('example')\n" +SCRIPT = ( + "from dataiku.scenario import Scenario\nScenario().set_scenario_variables(x=1)\n" +) + + +class _FakeScenario: + """Server-side scenario state, handed out as detached settings copies. + + Saving mirrors the SDK: the definition is written first, then the script, so + a failure between the two leaves the earlier write in place. + """ + + def __init__(self, definition: dict, script: str | None): + self.id = definition["id"] + self.definition = definition + self.script = script + self.definition_saves = 0 + self.script_saves = 0 + self.raise_on_definition_save: Exception | None = None + self.raise_on_script_save: Exception | None = None + self.stored_script_override: str | None = None + + def get_settings(self): + return _FakeScenarioSettings(self, copy.deepcopy(self.definition), self.script) + + +class _FakeScenarioSettings: + def __init__(self, server: _FakeScenario, definition: dict, script: str | None): + self.server = server + self.definition = definition + self._script = script + + def get_raw(self) -> dict: + return self.definition + + @property + def code(self) -> str: + if self.definition["type"] != "custom_python": + raise AttributeError("step-based settings have no code") + return self._script + + @code.setter + def code(self, script: str) -> None: + if self.definition["type"] != "custom_python": + raise AttributeError("step-based settings have no code") + self._script = script + + def save(self) -> None: + server = self.server + if server.raise_on_definition_save is not None: + raise server.raise_on_definition_save + server.definition_saves += 1 + server.definition = copy.deepcopy(self.definition) + if self.definition["type"] != "custom_python": + return + if server.raise_on_script_save is not None: + raise server.raise_on_script_save + server.script_saves += 1 + server.script = ( + server.stored_script_override + if server.stored_script_override is not None + else self._script + ) + + +class _FakeProject: + def __init__(self): + self.scenarios: dict[str, _FakeScenario] = {} + self.create_calls: list[tuple] = [] + self.raise_on_create: Exception | None = None + self.prepare_created = None + + def create_scenario(self, scenario_name, type, definition=None): + self.create_calls.append((scenario_name, type, copy.deepcopy(definition))) + if self.raise_on_create is not None: + raise self.raise_on_create + scenario = _FakeScenario( + { + **definition, + "id": "NIGHTLY_CHECK", + "name": scenario_name, + "type": type, + "projectKey": "PROJ", + }, + SAMPLE_SCRIPT, + ) + if self.prepare_created is not None: + self.prepare_created(scenario) + self.scenarios[scenario.id] = scenario + return scenario + + def get_scenario(self, scenario_id: str) -> _FakeScenario: + return self.scenarios[scenario_id] + + def list_scenarios(self) -> list[dict]: + return [ + {"id": scenario.id, "name": scenario.definition["name"]} + for scenario in self.scenarios.values() + ] + + +class _FakeClient: + def __init__(self, project: _FakeProject): + self.project = project + + def get_project(self, project_key: str) -> _FakeProject: + assert project_key == "PROJ" + return self.project + + +def _install(monkeypatch) -> _FakeProject: + project = _FakeProject() + monkeypatch.setattr(scenarios, "get_dss_client", lambda: _FakeClient(project)) + return project + + +def _existing(project: _FakeProject, scenario_type: str = "custom_python"): + definition = { + "id": "WEEKLY", + "projectKey": "PROJ", + "name": "Weekly refresh", + "type": scenario_type, + "active": True, + "tags": ["ops"], + "shortDesc": "Kept", + "params": {"envSelection": {"envMode": "INHERIT"}} + if scenario_type == "custom_python" + else {"steps": [{"type": "build_flowitem"}]}, + "triggers": [{"type": "temporal", "active": True}], + "reporters": [{"messaging": {"type": "mail-scenario"}}], + } + scenario = _FakeScenario( + definition, "print('old')\n" if scenario_type == "custom_python" else None + ) + project.scenarios[scenario.id] = scenario + return scenario + + +def _create(name: str = "Nightly check", script: str = SCRIPT) -> dict: + return json.loads( + asyncio.run( + scenarios.create_python_scenario("PROJ", name, script, FakeContext()) + ) + ) + + +def _set_script(script: str = SCRIPT, scenario_id: str = "WEEKLY") -> dict: + return json.loads( + asyncio.run( + scenarios.set_python_scenario_script( + "PROJ", scenario_id, script, FakeContext() + ) + ) + ) + + +def test_create_makes_an_inactive_trigger_free_python_scenario(monkeypatch): + project = _install(monkeypatch) + + result = _create() + + assert project.create_calls == [ + ( + "Nightly check", + "custom_python", + {"params": {}, "active": False, "triggers": [], "reporters": []}, + ) + ] + assert result == { + "status": "python_scenario_created", + "project_key": "PROJ", + "scenario_id": "NIGHTLY_CHECK", + "name": "Nightly check", + "type": "custom_python", + "active": False, + "trigger_count": 0, + } + assert project.scenarios["NIGHTLY_CHECK"].script == SCRIPT + + +def test_create_refuses_a_name_already_used(monkeypatch): + """A retried call must not leave a second scenario holding the sample script.""" + project = _install(monkeypatch) + _existing(project) + + with pytest.raises(ValueError, match=r"named 'Weekly refresh' \(id 'WEEKLY'\)"): + _create(name="Weekly refresh") + + assert project.create_calls == [] + + +def test_refused_create_propagates_and_creates_nothing(monkeypatch): + project = _install(monkeypatch) + project.raise_on_create = DataikuException( + "LicenseRestrictionException: Python scenarios are not available" + ) + + with pytest.raises(DataikuException, match="not available"): + _create() + + assert project.scenarios == {} + + +def test_unanswered_create_tells_the_caller_to_check_before_retrying(monkeypatch): + project = _install(monkeypatch) + project.raise_on_create = requests.ConnectionError("connection reset") + + with pytest.raises(RuntimeError, match="list_scenarios before retrying"): + _create() + + +@pytest.mark.parametrize( + "failure", + [ + "definition", + "script", + ], +) +def test_script_failure_after_create_reports_the_scenario_as_unconfirmed( + monkeypatch, failure +): + project = _install(monkeypatch) + + def fail(scenario): + error = DataikuException("You may not write code") + if failure == "definition": + scenario.raise_on_definition_save = error + else: + scenario.raise_on_script_save = error + + project.prepare_created = fail + + result = _create() + + assert result["status"] == "python_scenario_script_unconfirmed" + assert result["scenario_id"] == "NIGHTLY_CHECK" + assert result["error_type"] == "DataikuException" + assert "set_python_scenario_script" in result["hint"] + assert "Do not run it" in result["hint"] + created = project.scenarios["NIGHTLY_CHECK"] + assert created.script == SAMPLE_SCRIPT + assert created.definition["active"] is False + assert created.definition["triggers"] == [] + + +def test_unmatched_script_read_back_is_not_reported_as_created(monkeypatch): + project = _install(monkeypatch) + project.prepare_created = lambda scenario: setattr( + scenario, "stored_script_override", "print('other')\n" + ) + + result = _create() + + assert result["status"] == "python_scenario_script_unconfirmed" + assert "error" not in result + + +def test_set_script_replaces_only_the_script(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project) + before = copy.deepcopy(scenario.definition) + + result = _set_script() + + assert result == { + "project_key": "PROJ", + "scenario_id": "WEEKLY", + "name": "Weekly refresh", + "type": "custom_python", + "active": True, + "trigger_count": 1, + } + assert scenario.script == SCRIPT + assert scenario.definition == before + assert (scenario.definition_saves, scenario.script_saves) == (1, 1) + + +def test_set_script_refuses_a_step_based_scenario(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project, "step_based") + + with pytest.raises(ValueError, match="'step_based' scenario"): + _set_script() + + assert scenario.definition_saves == 0 + + +def test_set_script_refused_by_dataiku_keeps_the_old_script(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project) + scenario.raise_on_definition_save = DataikuException("You may not write code") + + with pytest.raises(DataikuException, match="may not write code"): + _set_script() + + assert scenario.script == "print('old')\n" + + +def test_set_script_unmatched_read_back_raises(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project) + scenario.stored_script_override = "print('other')\n" + + with pytest.raises(RuntimeError, match="repeating this call is safe"): + _set_script() + + +@pytest.mark.parametrize( + ("call", "message"), + [ + (lambda: _create(name=" "), "name"), + (lambda: _create(script=" \n"), "script"), + (lambda: _set_script(script=""), "script"), + (lambda: _set_script(scenario_id=" "), "scenario_id"), + ], +) +def test_blank_arguments_are_refused_without_dataiku_calls(monkeypatch, call, message): + monkeypatch.setattr( + scenarios, "get_dss_client", lambda: pytest.fail("No SDK call is allowed") + ) + + with pytest.raises(ValueError, match=message): + call() + + +def _get_settings(scenario_id: str = "WEEKLY") -> dict: + return json.loads( + asyncio.run(scenarios.get_scenario_settings("PROJ", scenario_id, FakeContext())) + ) + + +def test_get_settings_includes_the_python_script(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project) + + assert _get_settings() == {**scenario.definition, "script": "print('old')\n"} + + +def test_get_settings_leaves_step_based_scenarios_unchanged(monkeypatch): + project = _install(monkeypatch) + scenario = _existing(project, "step_based") + + assert _get_settings() == scenario.definition + + +def test_mcp_dispatch_creates_on_design(monkeypatch): + monkeypatch.setattr( + stdio, + "_current_instance", + DSSInstance( + name="design", + url="https://example.com", + api_key="private-key", + api_ticket=None, + instance_type="design", + source="config", + no_check_certificate=False, + ), + ) + project = _install(monkeypatch) + + async def dispatch(): + async with Client(mcp) as client: + return await client.call_tool( + "create_python_scenario", + {"project_key": "PROJ", "name": "Nightly check", "script": SCRIPT}, + ) + + result = asyncio.run(dispatch()) + assert json.loads(result.content[0].text)["status"] == "python_scenario_created" + assert project.scenarios["NIGHTLY_CHECK"].script == SCRIPT diff --git a/tests/test_tool_surface.py b/tests/test_tool_surface.py index c3312899..ef896dbb 100644 --- a/tests/test_tool_surface.py +++ b/tests/test_tool_surface.py @@ -211,11 +211,13 @@ "recipes": frozenset({"get_recipe_settings", "list_recipes"}), "scenarios": frozenset( { + "create_python_scenario", "get_scenario_run_history", "get_scenario_settings", "list_messaging_channels", "list_scenarios", "run_scenario", + "set_python_scenario_script", } ), "semantic_models": frozenset(