Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions kale/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions kale/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions kale/config/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')."
)
1 change: 1 addition & 0 deletions kale/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
199 changes: 199 additions & 0 deletions kale/tests/unit_tests/test_output_path.py
Original file line number Diff line number Diff line change
@@ -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 == ""
19 changes: 19 additions & 0 deletions labextension/src/widgets/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface IKaleNotebookMetadata {

steps_defaults?: string[];
storage_class_name?: string;
output_path?: string;
}

export const DefaultState: IState = {
Expand All @@ -99,6 +100,7 @@ export const DefaultState: IState = {
base_image: '',
enable_caching: true, // Default value in KFP is true
steps_defaults: [],
output_path: '',
},
runDeployment: false,
deploymentType: 'compile',
Expand Down Expand Up @@ -190,6 +192,10 @@ export class KubeflowKaleLeftPanel extends React.Component<IProps, IState> {
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: {
Expand Down Expand Up @@ -441,6 +447,7 @@ export class KubeflowKaleLeftPanel extends React.Component<IProps, IState> {
base_image:
notebookMetadata['base_image'] || DefaultState.metadata.base_image,
steps_defaults: DefaultState.metadata.steps_defaults,
output_path: notebookMetadata['output_path'] || '',
};
this.setState({
metadata: metadata,
Expand Down Expand Up @@ -671,6 +678,17 @@ export class KubeflowKaleLeftPanel extends React.Component<IProps, IState> {
/>
);

const output_path_input = (
<Input
variant="standard"
inputIndex={0}
label={'Output Directory'}
updateValue={this.updateOutputPath}
value={this.state.metadata.output_path || ''}
placeholder={'e.g. pipelines/output'}
/>
);

const enable_caching_toggle = (
<FormControlLabel
control={
Expand Down Expand Up @@ -761,6 +779,7 @@ export class KubeflowKaleLeftPanel extends React.Component<IProps, IState> {
{experiment_name_input}
{pipeline_name_input}
{pipeline_desc_input}
{output_path_input}
{enable_caching_toggle}
</div>
</div>
Expand Down
Loading