From 5811c543f1180231984feaea4d73c6cdf49d0acd Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 28 Feb 2026 22:59:21 +0530 Subject: [PATCH 1/2] feat: allow users to set a relative path to save compiled Pipeline DSL Signed-off-by: Yash --- backend/kale/cli.py | 8 + backend/kale/compiler.py | 11 +- backend/kale/pipeline.py | 1 + .../kale/tests/unit_tests/test_output_path.py | 145 ++++++++++++++++++ labextension/package.json | 2 +- labextension/src/widgets/LeftPanel.tsx | 19 +++ 6 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 backend/kale/tests/unit_tests/test_output_path.py diff --git a/backend/kale/cli.py b/backend/kale/cli.py index 9cfb755be..5c0194bb2 100644 --- a/backend/kale/cli.py +++ b/backend/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/backend/kale/compiler.py b/backend/kale/compiler.py index b7410c0de..8acf6198a 100644 --- a/backend/kale/compiler.py +++ b/backend/kale/compiler.py @@ -304,9 +304,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/backend/kale/pipeline.py b/backend/kale/pipeline.py index 8b351d84d..9eb2de8af 100644 --- a/backend/kale/pipeline.py +++ b/backend/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="") @property def source_path(self): diff --git a/backend/kale/tests/unit_tests/test_output_path.py b/backend/kale/tests/unit_tests/test_output_path.py new file mode 100644 index 000000000..4aa213cce --- /dev/null +++ b/backend/kale/tests/unit_tests/test_output_path.py @@ -0,0 +1,145 @@ +# 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 + +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 diff --git a/labextension/package.json b/labextension/package.json index 716f9f0e7..650ffecea 100644 --- a/labextension/package.json +++ b/labextension/package.json @@ -13,7 +13,7 @@ }, "license": "Apache-2.0", "engines": { - "node": ">=22" + "node": ">=22" }, "author": { "name": "Stefano Fioravanzo", diff --git a/labextension/src/widgets/LeftPanel.tsx b/labextension/src/widgets/LeftPanel.tsx index 3477e156f..51eb0c857 100644 --- a/labextension/src/widgets/LeftPanel.tsx +++ b/labextension/src/widgets/LeftPanel.tsx @@ -81,6 +81,7 @@ export interface IKaleNotebookMetadata { steps_defaults?: string[]; storage_class_name?: string; + output_path?: string; } export const DefaultState: IState = { @@ -92,6 +93,7 @@ export const DefaultState: IState = { base_image: '', enable_caching: true, // Default value in KFP is true steps_defaults: [], + output_path: '', }, runDeployment: false, deploymentType: 'compile', @@ -180,6 +182,10 @@ export class KubeflowKaleLeftPanel extends React.Component { this.setState(prevState => ({ metadata: { ...prevState.metadata, pipeline_description: desc }, })); + updateOutputPath = (path: string) => + this.setState(prevState => ({ + metadata: { ...prevState.metadata, output_path: path }, + })); updateDockerImage = (name: string) => this.setState(prevState => ({ metadata: { @@ -396,6 +402,7 @@ export class KubeflowKaleLeftPanel extends React.Component { base_image: notebookMetadata['base_image'] || DefaultState.metadata.base_image, steps_defaults: DefaultState.metadata.steps_defaults, + output_path: notebookMetadata['output_path'] || '', }; this.setState({ metadata: metadata, @@ -609,6 +616,17 @@ export class KubeflowKaleLeftPanel extends React.Component { /> ); + const output_path_input = ( + + ); + const enable_caching_toggle = ( { {experiment_name_input} {pipeline_name_input} {pipeline_desc_input} + {output_path_input} {enable_caching_toggle} From 41fb34f487de4a57a0edca90108e8e39c1c469f5 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 21:27:57 +0530 Subject: [PATCH 2/2] feat: add output path validation Signed-off-by: Yash --- backend/kale/config/validators.py | 18 +++++++ backend/kale/pipeline.py | 2 +- .../kale/tests/unit_tests/test_output_path.py | 54 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/kale/config/validators.py b/backend/kale/config/validators.py index 1e288de3e..fac9303bb 100644 --- a/backend/kale/config/validators.py +++ b/backend/kale/config/validators.py @@ -13,6 +13,7 @@ # limitations under the License. from abc import ABC, abstractmethod +import os import re from typing import Any @@ -207,3 +208,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 is a safe relative path.""" + + def _validate(self, value: str): + if not value: + return + if os.path.isabs(value): + raise ValueError( + f"'{value}' is not a valid output directory. Please use a relative path" + " (e.g. 'pipelines/output')." + ) + if ".." in value.split(os.sep): + raise ValueError( + f"'{value}' is not a valid output directory. The path cannot contain '..'." + ) diff --git a/backend/kale/pipeline.py b/backend/kale/pipeline.py index 9eb2de8af..601a14859 100644 --- a/backend/kale/pipeline.py +++ b/backend/kale/pipeline.py @@ -115,7 +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="") + output_path = Field(type=str, default="", validators=[validators.OutputPathValidator]) @property def source_path(self): diff --git a/backend/kale/tests/unit_tests/test_output_path.py b/backend/kale/tests/unit_tests/test_output_path.py index 4aa213cce..d5e30e1ba 100644 --- a/backend/kale/tests/unit_tests/test_output_path.py +++ b/backend/kale/tests/unit_tests/test_output_path.py @@ -17,6 +17,8 @@ import os from unittest.mock import patch +import pytest + from kale import NotebookConfig from kale.compiler import Compiler from kale.pipeline import Pipeline, PipelineConfig @@ -143,3 +145,55 @@ def test_dsl_script_path_is_set(self, 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.*relative path"): + 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.*cannot contain"): + 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.*cannot contain"): + 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 == ""