diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index b30945c53062..f9ade068ade2 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -7,6 +7,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 `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()`. ### 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 20b9d980fe82..87089aca1143 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 @@ -85,6 +91,28 @@ def _extract_created_by(value: Any) -> Any: return _read_wire_value(value, "userName", "user_name", "userObjectId", "user_object_id") +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. @@ -530,6 +558,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/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", 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 c5c0fd88134a..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 @@ -384,3 +384,64 @@ def test_deployment_template_from_rest_object_creation_context_none_when_absent( template = DeploymentTemplate._from_rest_object(mock_rest) 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, 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