From f73449dcbda1169d6ec68dce9907a5283dde076e Mon Sep 17 00:00:00 2001 From: Ayushh Garg Date: Fri, 24 Jul 2026 12:02:51 +0530 Subject: [PATCH 1/4] Command node dropping node-level interactive services --- sdk/ml/azure-ai-ml/CHANGELOG.md | 1 + .../azure/ai/ml/_internal/entities/command.py | 44 ++++++++++++++++++- .../internal/unittests/test_pipeline_job.py | 24 ++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index ad7f06075619..f13ff7b2e0c3 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features Added ### Bugs Fixed +- Fixed internal pipeline `Command` node dropping node-level interactive `services` (SSH, JupyterLab, TensorBoard, VS Code, etc.) during serialization, which prevented interactive endpoints from being created for Singularity jobs. The `services` are now serialized into the pipeline REST request and round-tripped on deserialization, matching the public `Command` node behavior. - Fixed `MLClient.jobs.create_or_update`, `archive`, and `restore` failing for previously-fetched jobs across all job types by routing metadata-only edits through the RunHistory PATCH endpoint. - Fixed `DeploymentTemplate.creation_context` always being `None` when retrieved via `get()` or `list()`. The created/modified timestamps and identity returned by the service (as `createdTime` / `modifiedTime` / `createdBy`) are now populated on `creation_context`, making `DeploymentTemplate` consistent with `Model` and `Environment`. - Fixed `deployment_templates.list(name=...)` raising `AttributeError: 'str' object has no attribute 'request_timeout'`. In the list response, `requestSettings` / `livenessProbe` / `readinessProbe` arrive as stringified dicts nested under `properties`; these are now parsed before conversion, giving `list()` parity with `models.list()` / `environments.list()`. diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/_internal/entities/command.py b/sdk/ml/azure-ai-ml/azure/ai/ml/_internal/entities/command.py index 9ed732ecb486..726c32a4b5e1 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/_internal/entities/command.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/_internal/entities/command.py @@ -10,7 +10,9 @@ from ..._schema import PathAwareSchema from ..._schema.core.fields import DistributionField from ...entities import CommandJobLimits, JobResourceConfiguration -from ...entities._util import get_rest_dict_for_node_attrs +from ...entities._builders.command import _resolve_job_services +from ...entities._job.job_service import JobServiceBase +from ...entities._util import from_rest_dict_to_dummy_rest_object, get_rest_dict_for_node_attrs from .._schema.component import NodeType from ..entities.component import InternalComponent from ..entities.node import InternalBaseNode @@ -24,6 +26,7 @@ class Command(InternalBaseNode): def __init__(self, **kwargs): node_type = kwargs.pop("type", None) or NodeType.COMMAND + services = kwargs.pop("services", None) super(Command, self).__init__(type=node_type, **kwargs) self._init = True self._resources = kwargs.pop("resources", JobResourceConfiguration()) @@ -31,6 +34,7 @@ def __init__(self, **kwargs): self._environment = kwargs.pop("environment", None) self._environment_variables = kwargs.pop("environment_variables", None) self._limits = kwargs.pop("limits", CommandJobLimits()) + self._services = _resolve_job_services(services) self._init = False @property @@ -108,6 +112,30 @@ def resources(self) -> JobResourceConfiguration: def resources(self, value: JobResourceConfiguration): self._resources = value + @property + def services(self) -> Optional[Dict]: + """The interactive services for the node. + + This is an experimental parameter, and may change at any time. + Please see https://aka.ms/azuremlexperimental for more information. + + :return: The interactive services for the node. + :rtype: Optional[Dict] + """ + return self._services + + @services.setter + def services(self, value: Optional[Dict]) -> None: + """Sets the interactive services for the node. + + This is an experimental parameter, and may change at any time. + Please see https://aka.ms/azuremlexperimental for more information. + + :param value: The interactive services for the node. + :type value: Optional[Dict] + """ + self._services = _resolve_job_services(value) + @classmethod def _picked_fields_from_dict_to_rest_object(cls) -> List[str]: return ["environment", "limits", "resources", "environment_variables"] @@ -126,6 +154,9 @@ def _to_rest_object(self, **kwargs) -> dict: "resources": get_rest_dict_for_node_attrs(self.resources, clear_empty_value=True), } ) + services = get_rest_dict_for_node_attrs(self.services) + if services is not None: + rest_obj["services"] = services return rest_obj @classmethod @@ -138,6 +169,17 @@ def _from_rest_object_to_init_params(cls, obj): # handle limits if "limits" in obj and obj["limits"]: obj["limits"] = CommandJobLimits._from_rest_object(obj["limits"]) + + # services, pipeline node rest object are dicts while _from_rest_job_services expect RestJobService + if "services" in obj and obj["services"]: + services = {} + for service_name, service in obj["services"].items(): + # in rest object of a pipeline job, service will be transferred to a dict as + # it's attributes of a node, but JobService._from_rest_object expect a + # RestJobService, so we need to convert it back. Here we convert the dict to a + # dummy rest object which may work as a RestJobService instead. + services[service_name] = from_rest_dict_to_dummy_rest_object(service) + obj["services"] = JobServiceBase._from_rest_job_services(services) return obj diff --git a/sdk/ml/azure-ai-ml/tests/internal/unittests/test_pipeline_job.py b/sdk/ml/azure-ai-ml/tests/internal/unittests/test_pipeline_job.py index 3da4fa165915..a7c4765d6eda 100644 --- a/sdk/ml/azure-ai-ml/tests/internal/unittests/test_pipeline_job.py +++ b/sdk/ml/azure-ai-ml/tests/internal/unittests/test_pipeline_job.py @@ -428,6 +428,30 @@ def test_singularity_component_in_pipeline(self): } } + def test_interactive_services_in_internal_command_node(self): + # regression test for ICM 822596251 / Bug 5466757: internal Command node dropped + # node-level interactive services (ssh/jupyterlab/etc.) during serialization. + from azure.ai.ml.entities import JupyterLabJobService, SshJobService + + yaml_path = "./tests/test_configs/internal/command-component-ls/ls_command_component.yaml" + node_func: CommandComponent = load_component(yaml_path) + node = node_func() + node.services = { + "my_ssh": SshJobService(), + "my_jupyter": JupyterLabJobService(), + } + + rest_obj = node._to_rest_object() + assert rest_obj["services"] == { + "my_ssh": {"job_service_type": "SSH"}, + "my_jupyter": {"job_service_type": "JupyterLab"}, + } + + # services should round-trip back into typed JobService objects + init_params = Command._from_rest_object_to_init_params(dict(rest_obj)) + assert isinstance(init_params["services"]["my_ssh"], SshJobService) + assert isinstance(init_params["services"]["my_jupyter"], JupyterLabJobService) + def test_load_pipeline_job_with_internal_components_as_node(self): yaml_path = Path("./tests/test_configs/internal/helloworld/helloworld_component_scope.yml") scope_internal_func = load_component(source=yaml_path) From 363662bc881f6ff7138c192569856bb43ba485b5 Mon Sep 17 00:00:00 2001 From: Ayushh Garg Date: Fri, 24 Jul 2026 12:53:08 +0530 Subject: [PATCH 2/4] Regenerate api.metadata.yml --- sdk/ml/azure-ai-ml/api.metadata.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/ml/azure-ai-ml/api.metadata.yml b/sdk/ml/azure-ai-ml/api.metadata.yml index 14c9e4ed800a..fdfb16fb12ab 100644 --- a/sdk/ml/azure-ai-ml/api.metadata.yml +++ b/sdk/ml/azure-ai-ml/api.metadata.yml @@ -1,3 +1,3 @@ apiMdSha256: 81371e37ca26ea3c761bdb2104b7a0b89a0026216a97510bc16f533cf936e2d9 -parserVersion: 0.3.28 +parserVersion: 0.3.30 pythonVersion: 3.12.10 From 3025f7fbd828e1a47351a2661387e60704904b8c Mon Sep 17 00:00:00 2001 From: Ayushh Garg Date: Fri, 24 Jul 2026 16:20:57 +0530 Subject: [PATCH 3/4] fix macos cicd pipeline issue --- sdk/ml/azure-ai-ml/dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/ml/azure-ai-ml/dev_requirements.txt b/sdk/ml/azure-ai-ml/dev_requirements.txt index bc053e008f18..a88a674b7933 100644 --- a/sdk/ml/azure-ai-ml/dev_requirements.txt +++ b/sdk/ml/azure-ai-ml/dev_requirements.txt @@ -21,7 +21,7 @@ azure-mgmt-resourcegraph<9.0.0,>=2.0.0 azure-mgmt-resource<23.0.0,>=3.0.0 pytest-reportlog python-dotenv -azureml-dataprep-rslex>=2.22.0; platform_python_implementation == "CPython" and python_version < "3.13" +azureml-dataprep-rslex>=2.22.0; platform_python_implementation == "CPython" and python_version < "3.13" and (sys_platform != "darwin" or platform_machine != "arm64") azureml-dataprep-rslex>=2.22.0; platform_python_implementation == "PyPy" and python_version < "3.10" pip setuptools==77.0.3 \ No newline at end of file From aa369e0181d5a846101faa2d4c7f51b3f20337cd Mon Sep 17 00:00:00 2001 From: Ayushh Garg Date: Mon, 27 Jul 2026 19:22:06 +0530 Subject: [PATCH 4/4] test:skip tests macos arm64 --- .../dataset/unittests/test_data_operations.py | 9 +++++---- .../unittests/test_datastore_operations.py | 9 +++++---- .../unittests/test_dev_requirements.py | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/sdk/ml/azure-ai-ml/tests/dataset/unittests/test_data_operations.py b/sdk/ml/azure-ai-ml/tests/dataset/unittests/test_data_operations.py index 72cfe0d762d6..af52fe307a25 100644 --- a/sdk/ml/azure-ai-ml/tests/dataset/unittests/test_data_operations.py +++ b/sdk/ml/azure-ai-ml/tests/dataset/unittests/test_data_operations.py @@ -30,6 +30,7 @@ IS_CPYTHON = platform.python_implementation() == "CPython" IS_PYPY = platform.python_implementation() == "PyPy" +IS_MACOS_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64" @pytest.fixture @@ -582,8 +583,8 @@ def test_create_with_datastore( ) @pytest.mark.skipif( - (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)), - reason="Skipping because CPython version is >=3.13 or PyPy version is >=3.10. azureml.dataprep.rslex do not support it", + (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)) or IS_MACOS_ARM64, + reason="Skipping because azureml.dataprep.rslex is unavailable: CPython>=3.13, PyPy>=3.10, or macOS arm64 (no wheel).", ) def test_mount_persistent( self, @@ -606,8 +607,8 @@ def test_mount_persistent( mock_data_operations._compute_operation.update_data_mounts.assert_called_once() @pytest.mark.skipif( - (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)), - reason="Skipping because CPython version is >=3.13 or PyPy version is >=3.10. azureml.dataprep.rslex do not support it", + (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)) or IS_MACOS_ARM64, + reason="Skipping because azureml.dataprep.rslex is unavailable: CPython>=3.13, PyPy>=3.10, or macOS arm64 (no wheel).", ) def test_mount_non_persistent( self, diff --git a/sdk/ml/azure-ai-ml/tests/datastore/unittests/test_datastore_operations.py b/sdk/ml/azure-ai-ml/tests/datastore/unittests/test_datastore_operations.py index e5360ea9a123..3621bccfe5b5 100644 --- a/sdk/ml/azure-ai-ml/tests/datastore/unittests/test_datastore_operations.py +++ b/sdk/ml/azure-ai-ml/tests/datastore/unittests/test_datastore_operations.py @@ -12,6 +12,7 @@ IS_CPYTHON = platform.python_implementation() == "CPython" IS_PYPY = platform.python_implementation() == "PyPy" +IS_MACOS_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64" @pytest.fixture @@ -103,8 +104,8 @@ def test_create_body_is_json_serializable( json.dumps(body, cls=SdkJSONEncoder, exclude_readonly=True) @pytest.mark.skipif( - (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)), - reason="Skipping because CPython version is >=3.13 or PyPy version is >=3.10. azureml.dataprep.rslex do not support it", + (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)) or IS_MACOS_ARM64, + reason="Skipping because azureml.dataprep.rslex is unavailable: CPython>=3.13, PyPy>=3.10, or macOS arm64 (no wheel).", ) def test_mount_persistent( self, @@ -128,8 +129,8 @@ def test_mount_persistent( mock_datastore_operation._compute_operation.update_data_mounts.assert_called_once() @pytest.mark.skipif( - (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)), - reason="Skipping because CPython version is >=3.13 or PyPy version is >=3.10. azureml.dataprep.rslex do not support it", + (IS_CPYTHON and sys.version_info >= (3, 13)) or (IS_PYPY and sys.version_info >= (3, 10)) or IS_MACOS_ARM64, + reason="Skipping because azureml.dataprep.rslex is unavailable: CPython>=3.13, PyPy>=3.10, or macOS arm64 (no wheel).", ) def test_mount_non_persistent( self, diff --git a/sdk/ml/azure-ai-ml/tests/internal_utils/unittests/test_dev_requirements.py b/sdk/ml/azure-ai-ml/tests/internal_utils/unittests/test_dev_requirements.py index efc6747d6bc8..1fdf2e8d1422 100644 --- a/sdk/ml/azure-ai-ml/tests/internal_utils/unittests/test_dev_requirements.py +++ b/sdk/ml/azure-ai-ml/tests/internal_utils/unittests/test_dev_requirements.py @@ -8,6 +8,8 @@ PACKAGE_NAME_SCIKIT_IMAGE = "scikit-image" IS_CPYTHON = platform.python_implementation() == "CPython" IS_PYPY = platform.python_implementation() == "PyPy" +# azureml-dataprep-rslex publishes no macOS arm64 wheel, so dev_requirements.txt excludes it there. +IS_MACOS_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64" def is_package_installed(package_name): @@ -33,14 +35,23 @@ def test_package_not_installed_in_cpython_3_13(self): ), f"{PACKAGE_NAME_AZURE_ML_DATAPREP_RSLEX} should not be installed in CPython 3.13 or above environment." @pytest.mark.skipif( - not (IS_CPYTHON and sys.version_info < (3, 13)), - reason="Skipping because environment is not below cpython 3.13", + not (IS_CPYTHON and sys.version_info < (3, 13) and not IS_MACOS_ARM64), + reason="Skipping because environment is not below cpython 3.13, or is macOS arm64 (no rslex wheel)", ) def test_package_installed_below_cpython_3_13(self): assert is_package_installed( PACKAGE_NAME_AZURE_ML_DATAPREP_RSLEX ), f"{PACKAGE_NAME_AZURE_ML_DATAPREP_RSLEX} should be installed in CPython < 3.13." + @pytest.mark.skipif( + not IS_MACOS_ARM64, + reason="Skipping because environment is not macOS arm64", + ) + def test_package_not_installed_on_macos_arm64(self): + assert not is_package_installed( + PACKAGE_NAME_AZURE_ML_DATAPREP_RSLEX + ), f"{PACKAGE_NAME_AZURE_ML_DATAPREP_RSLEX} should not be installed on macOS arm64 (no wheel available)." + @pytest.mark.skipif( not (IS_CPYTHON and sys.version_info < (3, 14)), reason="Skipping because environment is not below cpython 3.14",