Skip to content

Commit 2aa6c49

Browse files
authored
feat: support setting path for node output directly (#26712)
* feat: support setting path for node output directly * fix: fix ci
1 parent c965239 commit 2aa6c49

File tree

5 files changed

+61
-11
lines changed

5 files changed

+61
-11
lines changed

sdk/ml/azure-ai-ml/azure/ai/ml/entities/_job/pipeline/_io.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def path(self) -> str:
155155

156156
@path.setter
157157
def path(self, path):
158+
# For un-configured input/output, we build a default data entry for them.
159+
self._build_default_data()
158160
if hasattr(self._data, "path"):
159161
self._data.path = path
160162
else:
@@ -361,7 +363,9 @@ def is_control(self) -> str:
361363
def _build_default_data(self):
362364
"""Build default data when output not configured."""
363365
if self._data is None:
364-
self._data = Output()
366+
# _meta will be None when node._component is not a Component object
367+
# so we just leave the type inference work to backend
368+
self._data = Output(type=None)
365369

366370
def _build_data(self, data, key=None):
367371
"""Build output data according to assigned input, eg: node.outputs.key = data"""
@@ -593,15 +597,13 @@ def _validate_inputs(cls, inputs):
593597

594598
def __getattr__(self, name: K) -> V:
595599
if name not in self:
596-
# pylint: disable=unnecessary-comprehension
597-
raise UnexpectedAttributeError(keyword=name, keywords=[key for key in self])
600+
raise UnexpectedAttributeError(keyword=name, keywords=list(self))
598601
return super().__getitem__(name)
599602

600603
def __getitem__(self, item: K) -> V:
601604
# We raise this exception instead of KeyError
602605
if item not in self:
603-
# pylint: disable=unnecessary-comprehension
604-
raise UnexpectedKeywordError(func_name="ParameterGroup", keyword=item, keywords=[key for key in self])
606+
raise UnexpectedKeywordError(func_name="ParameterGroup", keyword=item, keywords=list(self))
605607
return super().__getitem__(item)
606608

607609
# For Jupyter Notebook auto-completion
@@ -654,6 +656,13 @@ def __init__(self, outputs: dict, **kwargs):
654656
def __getattr__(self, item) -> NodeOutput:
655657
return self.__getitem__(item)
656658

659+
def __getitem__(self, item) -> NodeOutput:
660+
if item not in self:
661+
# We raise this exception instead of KeyError as OutputsAttrDict doesn't support add new item after
662+
# __init__.
663+
raise UnexpectedAttributeError(keyword=item, keywords=list(self))
664+
return super().__getitem__(item)
665+
657666
def __setattr__(self, key: str, value: Union[Data, Output]):
658667
if isinstance(value, Output):
659668
mode = value.mode

sdk/ml/azure-ai-ml/tests/dsl/unittests/test_component_func.py

+17-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
from pathlib import Path
22
from typing import Callable, Union
33

4-
import marshmallow
54
import pytest
65
from marshmallow import ValidationError
76

87
from azure.ai.ml import PyTorchDistribution, load_component
9-
from azure.ai.ml.entities import Component as ComponentEntity
108
from azure.ai.ml.entities import Data, JobResourceConfiguration
119
from azure.ai.ml.entities._builders import Command
1210
from azure.ai.ml.entities._inputs_outputs import Input, Output
1311
from azure.ai.ml.entities._job.pipeline._io import PipelineInput, PipelineOutput
1412
from azure.ai.ml.entities._job.pipeline._load_component import _generate_component_function
15-
from azure.ai.ml.exceptions import UnexpectedKeywordError, ValidationException
13+
from azure.ai.ml.exceptions import UnexpectedKeywordError, ValidationException, UnexpectedAttributeError
1614

1715
from .._util import _DSL_TIMEOUT_SECOND
1816

@@ -156,7 +154,22 @@ def test_component_outputs(self):
156154

157155
# configure mode and default Output is built
158156
component.outputs.component_out_path.mode = "upload"
159-
assert component._build_outputs() == {"component_out_path": Output(mode="upload")}
157+
assert component._build_outputs() == {"component_out_path": Output(type=None, mode="upload")}
158+
159+
test_output_path = "azureml://datastores/workspaceblobstore/paths/azureml/ps_copy_component/outputs/output_dir"
160+
component: Command = component_func()
161+
162+
# configure path and default Output is built
163+
component.outputs.component_out_path.path = test_output_path
164+
assert component._build_outputs() == {"component_out_path": Output(type=None, path=test_output_path)}
165+
166+
# non-existent output
167+
with pytest.raises(
168+
UnexpectedAttributeError,
169+
match="Got an unexpected attribute 'component_out_path_non', "
170+
"valid attributes: 'component_out_path'."
171+
):
172+
component.outputs["component_out_path_non"].path = test_output_path
160173

161174
# configure data
162175
component: Command = component_func()

sdk/ml/azure-ai-ml/tests/dsl/unittests/test_dsl_pipeline.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import logging
21
import os
32
from functools import partial
43
from io import StringIO
@@ -585,7 +584,7 @@ def pipeline(
585584
}
586585

587586
job_yaml = "./tests/test_configs/pipeline_jobs/helloworld_pipeline_job_data_options.yml"
588-
pipeline_job = load_job(source=job_yaml)
587+
pipeline_job: PipelineJob = load_job(source=job_yaml)
589588

590589
pipeline = pipeline(**{key: val for key, val in pipeline_job._build_inputs().items()})
591590
pipeline.inputs.job_in_data_by_store_path_and_mount.mode = "ro_mount"
@@ -605,6 +604,8 @@ def pipeline(
605604
actual_outputs = pipeline._build_outputs()
606605
for k, v in actual_outputs.items():
607606
v.mode = v.mode.lower()
607+
# outputs defined in yaml are all uri_folder, while its default value in dsl is None
608+
v.type = "uri_folder"
608609
assert pipeline_job._build_outputs() == actual_outputs
609610

610611
component_job = next(iter(pipeline_job.jobs.values()))._to_rest_object()

sdk/ml/azure-ai-ml/tests/internal/unittests/test_pipeline_job.py

+13
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,16 @@ def pipeline_func():
527527
if key.startswith("data_"):
528528
expected_inputs[key] = {"job_input_type": "mltable", "uri": "azureml:scope_tsv:1"}
529529
assert rest_obj.properties.jobs["node"]["inputs"] == expected_inputs
530+
531+
def test_pipeline_with_setting_node_output_directly(self) -> None:
532+
component_dir = Path(__file__).parent.parent.parent / "test_configs" / "internal" / "command-component"
533+
copy_func = load_component(component_dir / "command-linux/copy/component.yaml")
534+
535+
copy_file = copy_func(
536+
input_dir=None,
537+
file_names=None,
538+
)
539+
540+
copy_file.outputs.output_dir.path = "path_on_datastore"
541+
assert copy_file.outputs.output_dir.path == "path_on_datastore"
542+
assert copy_file.outputs.output_dir.type == "path"

sdk/ml/azure-ai-ml/tests/pipeline_job/unittests/test_pipeline_job_entity.py

+14
Original file line numberDiff line numberDiff line change
@@ -1454,3 +1454,17 @@ def test_comment_in_pipeline(self) -> None:
14541454
rest_pipeline_dict = pipeline_job._to_rest_object().as_dict()["properties"]
14551455
assert pipeline_dict["jobs"]["hello_world_component"]["comment"] == "arbitrary string"
14561456
assert rest_pipeline_dict["jobs"]["hello_world_component"]["comment"] == "arbitrary string"
1457+
1458+
def test_pipeline_node_default_output(self):
1459+
test_path = "./tests/test_configs/pipeline_jobs/helloworld_pipeline_job_with_component_output.yml"
1460+
pipeline: PipelineJob = load_job(source=test_path)
1461+
1462+
# pipeline level output
1463+
pipeline_output = pipeline.outputs["job_out_path_2"]
1464+
assert pipeline_output.mode == "upload"
1465+
1466+
# other node level output tests can be found in
1467+
# dsl/unittests/test_component_func.py::TestComponentFunc::test_component_outputs
1468+
# data-binding-expression
1469+
with pytest.raises(ValidationException, match="<class '.*'> does not support setting path."):
1470+
pipeline.jobs["merge_component_outputs"].outputs["component_out_path_1"].path = "xxx"

0 commit comments

Comments
 (0)