From b56d705fc7ad0f292843d1916a3b6386d37fffa5 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Wed, 8 Jul 2026 02:01:05 +0530 Subject: [PATCH 1/3] [azure-ai-ml] Fix deployment_templates.list(name=...) crashing on stringified settings In the list() response, requestSettings / livenessProbe / readinessProbe arrive as stringified dicts nested under properties (get() returns typed objects at the top level), so _from_rest_object raised 'str' object has no attribute 'request_timeout'. Parse and wrap those values into the typed REST models before conversion, giving list() parity with models.list() / environments.list(). Adds unit tests for both the list() and get() shapes. Verified live against the azure-huggingface registry. Fixes bug #5402672. --- sdk/ml/azure-ai-ml/CHANGELOG.md | 1 + .../_deployment/deployment_template.py | 35 ++++++ .../unittests/test_deployment_template.py | 115 ++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index 9d31633f75d3..94d734775e6d 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -6,6 +6,7 @@ ### Bugs Fixed - 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 `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()`. ### Other Changes diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/entities/_deployment/deployment_template.py b/sdk/ml/azure-ai-ml/azure/ai/ml/entities/_deployment/deployment_template.py index 3b9f6b6526df..0e7d75ac03e3 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/entities/_deployment/deployment_template.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/entities/_deployment/deployment_template.py @@ -11,6 +11,12 @@ from pathlib import Path from typing import IO, Any, AnyStr, Dict, List, Optional, Union +from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401.models import ( + OnlineRequestSettings as RestOnlineRequestSettings, +) +from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401.models import ( + ProbeSettings as RestProbeSettings, +) from azure.ai.ml._utils._experimental import experimental from azure.ai.ml.entities._assets import Environment from azure.ai.ml.entities._deployment.accelerator_map import AcceleratorMap @@ -19,6 +25,28 @@ from azure.ai.ml.entities._resource import Resource +def _coerce_rest_settings(value: Any, rest_cls: type) -> Any: + """Coerce a settings value into ``rest_cls`` so attribute-based converters work. + + In the ``list()`` response, settings such as ``requestSettings`` / ``livenessProbe`` / + ``readinessProbe`` arrive as stringified dicts nested under ``properties`` (whereas + ``get()`` returns typed objects at the top level). Parse the string and wrap the + resulting dict into the typed REST model so ``_from_rest_object`` works for both shapes. + """ + import ast + + if value is None or isinstance(value, rest_cls): + return value + if isinstance(value, str): + try: + value = ast.literal_eval(value) + except (ValueError, SyntaxError): + return None + if isinstance(value, dict): + return rest_cls(value) + return value + + @experimental class DeploymentTemplate(Resource, RestTranslatableMixin): # pylint: disable=too-many-instance-attributes """DeploymentTemplate entity for Azure ML deployments. @@ -464,6 +492,13 @@ def get_value(source, key, default=None): except (ValueError, SyntaxError): allowed_environment_variable_overrides = None + # In the list() response shape these settings arrive as stringified dicts nested under + # ``properties`` (get() returns typed objects at the top level instead). Coerce them into + # the typed REST models so the attribute-based converters below work for both shapes. + request_settings = _coerce_rest_settings(request_settings, RestOnlineRequestSettings) + liveness_probe = _coerce_rest_settings(liveness_probe, RestProbeSettings) + readiness_probe = _coerce_rest_settings(readiness_probe, RestProbeSettings) + # Convert request_settings to OnlineRequestSettings object using the built-in conversion method request_settings_obj = OnlineRequestSettings._from_rest_object(request_settings) if request_settings else None diff --git a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py index 104f200cfeb3..c091726395b2 100644 --- a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py +++ b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py @@ -295,3 +295,118 @@ def test_deployment_template_from_rest_object_none(self): # Should handle None gracefully assert result is not None or result is None # Allow either behavior + + def test_deployment_template_from_rest_object_list_shape_stringified_settings(self): + """Regression test for bug #5402672. + + In the list() response, requestSettings / livenessProbe / readinessProbe arrive as + stringified dicts nested under ``properties`` (get() returns typed objects instead). + _from_rest_object must parse them instead of raising + ``AttributeError: 'str' object has no attribute 'request_timeout'``. + """ + mock_rest = Mock(spec=[]) + mock_rest.name = "test-template" + mock_rest.id = None + mock_rest.request_settings = None + mock_rest.liveness_probe = None + mock_rest.readiness_probe = None + mock_rest.properties = { + "name": "test-template", + "version": "5", + "requestSettings": "{'requestTimeout': 'PT1M30S', 'maxConcurrentRequestsPerInstance': 32}", + "livenessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " + "'failureThreshold': 30, 'successThreshold': 1, 'path': '/health', 'port': 8000}", + "readinessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " + "'failureThreshold': 30, 'successThreshold': 1, 'path': '/health', 'port': 8000}", + } + + template = DeploymentTemplate._from_rest_object(mock_rest) + + # request settings parsed: PT1M30S == 90000 ms + assert template.request_settings is not None + assert template.request_settings.request_timeout_ms == 90000 + assert template.request_settings.max_concurrent_requests_per_instance == 32 + # probes parsed + assert template.liveness_probe is not None + assert template.liveness_probe.failure_threshold == 30 + assert template.readiness_probe is not None + assert template.readiness_probe.failure_threshold == 30 + + def test_deployment_template_from_rest_object_get_shape_typed_settings(self): + """The get() shape (already-typed request settings object) must keep working.""" + from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401 import models as rest_models + + rest = rest_models.DeploymentTemplate( + { + "name": "test-template", + "version": "5", + "requestSettings": {"requestTimeout": "PT1M30S", "maxConcurrentRequestsPerInstance": 16}, + } + ) + + template = DeploymentTemplate._from_rest_object(rest) + + assert template.request_settings is not None + assert template.request_settings.request_timeout_ms == 90000 + assert template.request_settings.max_concurrent_requests_per_instance == 16 + + def test_deployment_template_from_rest_object_list_shape_stringified_settings(self): + """Regression test for bug #5402672. + + In the list() response, settings such as requestSettings / livenessProbe / + readinessProbe arrive as stringified dicts nested under ``properties`` (rather + than as typed objects at the top level like get() returns). _from_rest_object + must parse them instead of raising + ``AttributeError: 'str' object has no attribute 'request_timeout'``. + """ + mock_rest = Mock(spec=[]) + mock_rest.name = "list-shape-template" + mock_rest.id = None + # Top-level typed fields are None in the list() shape. + mock_rest.request_settings = None + mock_rest.liveness_probe = None + mock_rest.readiness_probe = None + # The real data is nested (and stringified) under properties. + mock_rest.properties = { + "name": "list-shape-template", + "version": "5", + "requestSettings": "{'requestTimeout': 'PT1M30S', 'maxConcurrentRequestsPerInstance': 32}", + "livenessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " + "'failureThreshold': 30, 'successThreshold': 1}", + "readinessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " + "'failureThreshold': 30, 'successThreshold': 1}", + } + + # Must not raise, and must parse the stringified settings into real objects. + template = DeploymentTemplate._from_rest_object(mock_rest) + + assert template.request_settings is not None + assert template.request_settings.request_timeout_ms == 90000 + assert template.request_settings.max_concurrent_requests_per_instance == 32 + assert template.liveness_probe is not None + assert template.liveness_probe.failure_threshold == 30 + assert template.readiness_probe is not None + assert template.readiness_probe.failure_threshold == 30 + + def test_deployment_template_from_rest_object_get_shape_typed_settings(self): + """Parity check: the get() shape (typed request_settings at top level) still works.""" + from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401.models import ( + OnlineRequestSettings as RestOnlineRequestSettings, + ) + + mock_rest = Mock(spec=[]) + mock_rest.name = "get-shape-template" + mock_rest.id = None + mock_rest.properties = None + # get() returns the typed REST settings object (wire keys, snake attrs) at the top level. + mock_rest.request_settings = RestOnlineRequestSettings( + {"requestTimeout": "PT1M30S", "maxConcurrentRequestsPerInstance": 16} + ) + mock_rest.liveness_probe = None + mock_rest.readiness_probe = None + + template = DeploymentTemplate._from_rest_object(mock_rest) + + assert template.request_settings is not None + assert template.request_settings.request_timeout_ms == 90000 + assert template.request_settings.max_concurrent_requests_per_instance == 16 From 4567fab7b399c39b79b8126d99084394bb6f5b06 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Wed, 8 Jul 2026 18:33:28 +0530 Subject: [PATCH 2/3] [azure-ai-ml] Remove duplicate DeploymentTemplate test methods Address PR review (Copilot): test_deployment_template_from_rest_object_list_shape_stringified_settings and _get_shape_typed_settings were each defined twice after the merge, so the earlier pair was dead code shadowed by the later definitions. Removed the duplicate first pair; the later (executing) definitions are kept. --- .../unittests/test_deployment_template.py | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py index 8d6512fd2406..577ee654f407 100644 --- a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py +++ b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template.py @@ -385,60 +385,6 @@ def test_deployment_template_from_rest_object_creation_context_none_when_absent( assert template.creation_context is None - def test_deployment_template_from_rest_object_list_shape_stringified_settings(self): - """Regression test for bug #5402672. - - In the list() response, requestSettings / livenessProbe / readinessProbe arrive as - stringified dicts nested under ``properties`` (get() returns typed objects instead). - _from_rest_object must parse them instead of raising - ``AttributeError: 'str' object has no attribute 'request_timeout'``. - """ - mock_rest = Mock(spec=[]) - mock_rest.name = "test-template" - mock_rest.id = None - mock_rest.request_settings = None - mock_rest.liveness_probe = None - mock_rest.readiness_probe = None - mock_rest.properties = { - "name": "test-template", - "version": "5", - "requestSettings": "{'requestTimeout': 'PT1M30S', 'maxConcurrentRequestsPerInstance': 32}", - "livenessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " - "'failureThreshold': 30, 'successThreshold': 1, 'path': '/health', 'port': 8000}", - "readinessProbe": "{'initialDelay': 'PT5M', 'period': 'PT10S', 'timeout': 'PT10S', " - "'failureThreshold': 30, 'successThreshold': 1, 'path': '/health', 'port': 8000}", - } - - template = DeploymentTemplate._from_rest_object(mock_rest) - - # request settings parsed: PT1M30S == 90000 ms - assert template.request_settings is not None - assert template.request_settings.request_timeout_ms == 90000 - assert template.request_settings.max_concurrent_requests_per_instance == 32 - # probes parsed - assert template.liveness_probe is not None - assert template.liveness_probe.failure_threshold == 30 - assert template.readiness_probe is not None - assert template.readiness_probe.failure_threshold == 30 - - def test_deployment_template_from_rest_object_get_shape_typed_settings(self): - """The get() shape (already-typed request settings object) must keep working.""" - from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401 import models as rest_models - - rest = rest_models.DeploymentTemplate( - { - "name": "test-template", - "version": "5", - "requestSettings": {"requestTimeout": "PT1M30S", "maxConcurrentRequestsPerInstance": 16}, - } - ) - - template = DeploymentTemplate._from_rest_object(rest) - - assert template.request_settings is not None - assert template.request_settings.request_timeout_ms == 90000 - assert template.request_settings.max_concurrent_requests_per_instance == 16 - def test_deployment_template_from_rest_object_list_shape_stringified_settings(self): """Regression test for bug #5402672. From c5b9fb3a642ec92912a636fadd675fb15b0a6ba0 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Wed, 8 Jul 2026 23:35:08 +0530 Subject: [PATCH 3/3] [azure-ai-ml] Add azureaiassetsv to cspell ignore list Build Analyze (cspell) flagged 'azureaiassetsv' (from the azureaiassetsv20240401 import path) in deployment_template.py:14 and :17. Added the word to the package cspell.json ignore list, consistent with how 'restclient' from the same _restclient import path is already handled. Verified locally with the root .vscode/cspell.json config: 0 issues. --- sdk/ml/azure-ai-ml/cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/ml/azure-ai-ml/cspell.json b/sdk/ml/azure-ai-ml/cspell.json index d1566bbf2e25..d5ddd5fe87a9 100644 --- a/sdk/ml/azure-ai-ml/cspell.json +++ b/sdk/ml/azure-ai-ml/cspell.json @@ -8,6 +8,7 @@ "aadtoken", "alds", "arimax", + "azureaiassetsv", "elts", "entra", "expirable",