Skip to content
Merged
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
1 change: 1 addition & 0 deletions sdk/ml/azure-ai-ml/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sdk/ml/azure-ai-ml/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"aadtoken",
"alds",
"arimax",
"azureaiassetsv",
"elts",
"entra",
"expirable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading