diff --git a/kale/cli.py b/kale/cli.py index 9cfb755be..5c0194bb2 100644 --- a/kale/cli.py +++ b/kale/cli.py @@ -106,6 +106,14 @@ def main(): metadata_group.add_argument( "--volume-access-mode", type=str, help="The access mode for the created volumes" ) + metadata_group.add_argument( + "--output_path", + type=str, + help=( + "Relative path (from the notebook directory) where the compiled " + "KFP DSL Python script will be saved. Defaults to '.kale/'." + ), + ) args = parser.parse_args() if args.pip_index_urls: diff --git a/kale/compiler.py b/kale/compiler.py index 8da3e7aa2..3481ec54b 100644 --- a/kale/compiler.py +++ b/kale/compiler.py @@ -308,9 +308,14 @@ def _get_templating_env(self, templates_path=None): def _save_compiled_code(self, path: str = None) -> str: if not path: - # save the generated file in a hidden local directory - path = os.path.join(os.getcwd(), ".kale") - os.makedirs(path, exist_ok=True) + config_output_path = self.pipeline.config.output_path + if config_output_path: + # Resolve relative to CWD (the notebook's working directory) + path = os.path.join(os.getcwd(), config_output_path) + else: + # Default: save in hidden .kale/ directory + path = os.path.join(os.getcwd(), ".kale") + os.makedirs(path, exist_ok=True) log.info("Saving generated code in %s", path) filename = f"{self.pipeline.config.pipeline_name}.kale.py" output_path = os.path.abspath(os.path.join(path, filename)) diff --git a/kale/config/validators.py b/kale/config/validators.py index 1e288de3e..d946af684 100644 --- a/kale/config/validators.py +++ b/kale/config/validators.py @@ -207,3 +207,20 @@ def _validate(self, value): raise ValueError(f"'{value}' is not of type 'int'") if value <= 0: raise ValueError(f"'{value}' is not a positive integer") + + +class OutputPathValidator(Validator): + """Validates that an output path resolves to within the project directory.""" + + def _validate(self, value: str): + if not value: + return + from pathlib import Path + + project_dir = Path.cwd().resolve() + resolved = (project_dir / value).resolve() + if not str(resolved).startswith(str(project_dir)): + raise ValueError( + f"'{value}' is not a valid output directory. The path must be" + " relative to the project directory (e.g. 'pipelines/output')." + ) diff --git a/kale/pipeline.py b/kale/pipeline.py index a327b9650..6cee9a227 100644 --- a/kale/pipeline.py +++ b/kale/pipeline.py @@ -115,6 +115,7 @@ class PipelineConfig(Config): type=str, validators=[validators.IsLowerValidator, validators.VolumeAccessModeValidator] ) timeout = Field(type=int, validators=[validators.PositiveIntegerValidator]) + output_path = Field(type=str, default="", validators=[validators.OutputPathValidator]) @property def source_path(self): diff --git a/kale/tests/unit_tests/test_output_path.py b/kale/tests/unit_tests/test_output_path.py new file mode 100644 index 000000000..363cfb76e --- /dev/null +++ b/kale/tests/unit_tests/test_output_path.py @@ -0,0 +1,199 @@ +# Copyright 2026 The Kubeflow Authors. +# +# 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 the configurable DSL output path feature.""" + +import os +from unittest.mock import patch + +import pytest + +from kale import NotebookConfig +from kale.compiler import Compiler +from kale.pipeline import Pipeline, PipelineConfig +from kale.step import Step + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_pipeline(output_path: str = "") -> Pipeline: + """Create a minimal one-step pipeline with the given output_path.""" + config = PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path=output_path, + ) + pipeline = Pipeline(config) + step = Step(name="step_one", source=["x = 1"]) + pipeline.add_step(step) + return pipeline + + +def _make_compiler(pipeline: Pipeline) -> Compiler: + """Create a Compiler wrapping the given pipeline.""" + compiler = Compiler(pipeline, imports_and_functions="") + compiler.dsl_source = "# generated DSL" + return compiler + + +# --------------------------------------------------------------------------- +# PipelineConfig field tests +# --------------------------------------------------------------------------- + + +class TestOutputPathField: + def test_default_is_empty_string(self, dummy_nb_config): + config = NotebookConfig(**dummy_nb_config) + assert config.output_path == "" + + def test_can_be_set_via_kwargs(self, dummy_nb_config): + config = NotebookConfig(**{**dummy_nb_config, "output_path": "my_output"}) + assert config.output_path == "my_output" + + def test_survives_to_dict(self, dummy_nb_config): + config = NotebookConfig(**{**dummy_nb_config, "output_path": "dsl_out"}) + assert config.to_dict()["output_path"] == "dsl_out" + + def test_empty_string_survives_to_dict(self, dummy_nb_config): + config = NotebookConfig(**dummy_nb_config) + assert config.to_dict()["output_path"] == "" + + +# --------------------------------------------------------------------------- +# Compiler._save_compiled_code tests +# --------------------------------------------------------------------------- + + +class TestSaveCompiledCode: + def test_default_saves_to_kale_dir(self, tmp_path): + """When output_path is empty the DSL goes into .kale/ under CWD.""" + pipeline = _make_pipeline(output_path="") + compiler = _make_compiler(pipeline) + + with patch("os.getcwd", return_value=str(tmp_path)): + result = compiler._save_compiled_code() + + expected_dir = tmp_path / ".kale" + assert expected_dir.is_dir() + assert result == str(expected_dir / "test-pipeline.kale.py") + + def test_custom_relative_path_is_used(self, tmp_path): + """When output_path is set the DSL is written to that relative path.""" + pipeline = _make_pipeline(output_path="my_dsl_output") + compiler = _make_compiler(pipeline) + + with patch("os.getcwd", return_value=str(tmp_path)): + result = compiler._save_compiled_code() + + expected_dir = tmp_path / "my_dsl_output" + assert expected_dir.is_dir() + assert result == str(expected_dir / "test-pipeline.kale.py") + + def test_custom_nested_relative_path_is_used(self, tmp_path): + """Nested relative paths like 'a/b/c' are created as needed.""" + pipeline = _make_pipeline(output_path="compiled/pipelines") + compiler = _make_compiler(pipeline) + + with patch("os.getcwd", return_value=str(tmp_path)): + result = compiler._save_compiled_code() + + expected_dir = tmp_path / "compiled" / "pipelines" + assert expected_dir.is_dir() + assert result == str(expected_dir / "test-pipeline.kale.py") + + def test_absolute_path_argument_takes_precedence(self, tmp_path): + """An explicit path= argument overrides both config and default.""" + pipeline = _make_pipeline(output_path="should_be_ignored") + compiler = _make_compiler(pipeline) + explicit_dir = str(tmp_path / "explicit") + + result = compiler._save_compiled_code(path=explicit_dir) + + assert os.path.isdir(explicit_dir) + assert result == os.path.join(explicit_dir, "test-pipeline.kale.py") + + def test_dsl_content_is_written(self, tmp_path): + """The generated DSL source is actually written to disk.""" + pipeline = _make_pipeline(output_path="out") + compiler = _make_compiler(pipeline) + compiler.dsl_source = "# my pipeline code" + + with patch("os.getcwd", return_value=str(tmp_path)): + result = compiler._save_compiled_code() + + assert open(result).read() == "# my pipeline code" + + def test_dsl_script_path_is_set(self, tmp_path): + """After saving, compiler.dsl_script_path points to the written file.""" + pipeline = _make_pipeline(output_path="") + compiler = _make_compiler(pipeline) + + with patch("os.getcwd", return_value=str(tmp_path)): + result = compiler._save_compiled_code() + + assert compiler.dsl_script_path == result + + +# --------------------------------------------------------------------------- +# Invalid output_path validation tests +# --------------------------------------------------------------------------- + + +class TestOutputPathValidation: + def test_absolute_path_is_rejected(self): + """Absolute paths like '/tmp/output' should be rejected.""" + with pytest.raises(ValueError, match="not a valid output directory"): + PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path="/tmp/output", + ) + + def test_dotdot_path_is_rejected(self): + """Paths containing '..' should be rejected.""" + with pytest.raises(ValueError, match="not a valid output directory"): + PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path="../outside", + ) + + def test_dotdot_nested_path_is_rejected(self): + """Nested paths with '..' like 'foo/../../bar' should be rejected.""" + with pytest.raises(ValueError, match="not a valid output directory"): + PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path="foo/../../bar", + ) + + def test_valid_relative_path_is_accepted(self): + """Normal relative paths like 'pipelines/output' should work fine.""" + config = PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path="pipelines/output", + ) + assert config.output_path == "pipelines/output" + + def test_empty_string_is_accepted(self): + """Empty string (default) should pass validation.""" + config = PipelineConfig( + pipeline_name="test-pipeline", + experiment_name="test-experiment", + output_path="", + ) + assert config.output_path == "" diff --git a/labextension/schema/kale-settings.json b/labextension/schema/kale-settings.json index 2b3db84c8..0e88d5b3f 100644 --- a/labextension/schema/kale-settings.json +++ b/labextension/schema/kale-settings.json @@ -15,6 +15,12 @@ "title": "Auto-save the notebook when compiling or running", "description": "When enabled, Kale saves the notebook automatically before compile, run, or upload without prompting. When disabled, you are asked to save if there are unsaved changes.", "default": false + }, + "outputPath": { + "type": "string", + "title": "Pipeline output directory", + "description": "Relative path (from the notebook directory) where the compiled KFP DSL Python script is saved. Leave empty to use the default '.kale/' directory. The path must resolve inside the notebook's project directory.", + "default": ".kale" } }, "additionalProperties": false, diff --git a/labextension/src/widget.tsx b/labextension/src/widget.tsx index 535742a38..2e748a0f8 100644 --- a/labextension/src/widget.tsx +++ b/labextension/src/widget.tsx @@ -58,6 +58,7 @@ const id = 'jupyterlab-kubeflow-kale:deploymentPanel'; const KALE_SETTINGS_PLUGIN_ID = 'jupyterlab-kubeflow-kale:kale-settings'; const ENABLE_KALE_BY_DEFAULT_KEY = 'enableKaleByDefault'; const AUTO_SAVE_ON_COMPILE_OR_RUN_KEY = 'autoSaveOnCompileOrRun'; +const OUTPUT_PATH_KEY = 'outputPath'; const kaleIcon = new LabIcon({ name: 'kale:logo', svgstr: kaleIconSvg }); let kalePanelWidget: ReactWidget | undefined; @@ -123,6 +124,7 @@ async function activate( const [kaleSettings, setKaleSettings] = React.useState({ enableKaleByDefault: false, autoSaveOnCompileOrRun: false, + outputPath: '', }); React.useEffect(() => { @@ -144,6 +146,10 @@ async function activate( (loadedSetting.get(AUTO_SAVE_ON_COMPILE_OR_RUN_KEY).composite as | boolean | undefined) ?? false, + outputPath: + (loadedSetting.get(OUTPUT_PATH_KEY).composite as + | string + | undefined) ?? '', }); const update = () => { @@ -179,6 +185,7 @@ async function activate( kernel={kernel} enableKaleByDefault={kaleSettings.enableKaleByDefault} autoSaveOnCompileOrRun={kaleSettings.autoSaveOnCompileOrRun} + outputPath={kaleSettings.outputPath} /> ); }; diff --git a/labextension/src/widgets/LeftPanel.tsx b/labextension/src/widgets/LeftPanel.tsx index a91bb9b02..a4e4751e3 100644 --- a/labextension/src/widgets/LeftPanel.tsx +++ b/labextension/src/widgets/LeftPanel.tsx @@ -26,7 +26,8 @@ import { import { JupyterFrontEnd } from '@jupyterlab/application'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { ThemeProvider } from '@mui/material/styles'; -import { FormControlLabel, Switch } from '@mui/material'; +import { FormControlLabel, Link, Switch } from '@mui/material'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import { theme } from '../Theme'; import { Input } from '../components/Input'; import Commands from '../lib/Commands'; @@ -61,6 +62,7 @@ interface IProps { kernel: Kernel.IKernelConnection; enableKaleByDefault: boolean; autoSaveOnCompileOrRun: boolean; + outputPath: string; } interface IState { @@ -90,6 +92,7 @@ export interface IKaleNotebookMetadata { steps_defaults?: string[]; storage_class_name?: string; + output_path?: string; } export const DefaultState: IState = { @@ -228,6 +231,13 @@ export class KubeflowKaleLeftPanel extends React.Component { deployDebugMessage: !prevState.deployDebugMessage, })); + openKaleSettings = () => { + // Settings Editor filter matches schema title, not plugin id. + this.props.lab.commands.execute('settingeditor:open', { + query: 'Kale', + }); + }; + // restore state to default values resetState = () => this.setState(prevState => ({ @@ -541,6 +551,11 @@ export class KubeflowKaleLeftPanel extends React.Component { metadata.base_image = DefaultState.metadata.base_image; } + // outputPath comes from JupyterLab Settings; backend expects it as output_path. + if (this.props.outputPath) { + metadata.output_path = this.props.outputPath; + } + const nbFilePath = this.getActiveNotebookPath(); if (!nbFilePath) { @@ -789,6 +804,25 @@ export class KubeflowKaleLeftPanel extends React.Component { {pipeline_desc_input} {enable_caching_toggle} + +
+ + + Advanced Kale settings live in JupyterLab Settings.{' '} + + Open settings + + +