Skip to content
Open
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 @@ -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 `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`.

### Other Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,72 @@
from azure.ai.ml.entities._deployment.deployment_template_settings import OnlineRequestSettings, ProbeSettings
from azure.ai.ml.entities._mixins import RestTranslatableMixin
from azure.ai.ml.entities._resource import Resource
from azure.ai.ml.entities._system_data import SystemData


def _read_wire_value(source: Any, *keys: str) -> Any:
"""Read the first non-None value for any of ``keys`` from a REST object.

Handles both mapping-style access (wire/camelCase keys such as ``createdTime``) and
attribute-style access (snake_case such as ``created_time``). This is needed because
some service fields are returned as undeclared/flattened keys that only exist on the
backing mapping of the generated REST model, not as typed attributes.
"""
if source is None:
return None
for key in keys:
getter = getattr(source, "get", None)
if callable(getter) and not isinstance(source, str):
try:
value = source.get(key)
except Exception: # pylint: disable=broad-except
value = None
if value is not None:
return value
value = getattr(source, key, None)
if value is not None:
return value
return None


def _parse_iso_datetime(value: Any) -> Any:
"""Best-effort parse of an ISO-8601 timestamp string into a ``datetime``.

Returns the original value unchanged when it is not a parseable string, so the raw
timestamp is still surfaced rather than dropped.
"""
if not isinstance(value, str):
return value
try:
import isodate

return isodate.parse_datetime(value)
except Exception: # pylint: disable=broad-except
return value


def _extract_created_by(value: Any) -> Any:
"""Extract a readable ``created_by`` identity from a service ``createdBy`` value.

The value may be a plain string identity, an object/dict such as ``{"userName": ...}``
(get() shape), or a stringified dict such as ``"{'userName': ...}"`` (list() shape).
Returns the ``userName`` (or object id) when available, otherwise the value unchanged.
"""
if value is None:
return None
if isinstance(value, str):
# list() returns createdBy as a stringified dict; parse it, else treat as a plain identity.
import ast

try:
parsed = ast.literal_eval(value)
except (ValueError, SyntaxError):
return value
if isinstance(parsed, dict):
value = parsed
else:
return value
return _read_wire_value(value, "userName", "user_name", "userObjectId", "user_object_id")


@experimental
Expand Down Expand Up @@ -486,6 +552,34 @@ def get_value(source, key, default=None):
except (ValueError, TypeError):
scoring_port = None

# Populate creation_context so DeploymentTemplate is consistent with Model and
# Environment. Those entities return a nested ``systemData`` block, but the
# deployment-template API (e.g. the azure-huggingface registry) instead returns
# flattened ``createdTime`` / ``modifiedTime`` / ``createdBy`` fields. These live at the
# top level in the get() response but nested under ``properties`` in the list() response,
# so read properties-first with an obj fallback (matching the other fields above).
system_data = get_value(obj, "system_data") or get_value(obj, "systemData")
if system_data is not None:
creation_context = SystemData._from_rest_object(system_data)
else:
created_time = _read_wire_value(properties, "createdTime", "createdAt") or _read_wire_value(
obj, "createdTime", "created_time", "createdAt", "created_at"
)
modified_time = _read_wire_value(properties, "modifiedTime", "lastModifiedAt") or _read_wire_value(
obj, "modifiedTime", "modified_time", "lastModifiedAt", "last_modified_at"
)
created_by = _extract_created_by(
_read_wire_value(properties, "createdBy") or _read_wire_value(obj, "createdBy", "created_by")
)
if created_time or modified_time or created_by:
creation_context = SystemData(
created_at=_parse_iso_datetime(created_time),
created_by=created_by,
last_modified_at=_parse_iso_datetime(modified_time),
)
else:
creation_context = None

template = cls(
name=name or "unknown",
version=version or "1.0",
Expand All @@ -494,6 +588,7 @@ def get_value(source, key, default=None):
tags=tags, # Include tags from REST response
properties=properties, # Include properties from REST response
id=get_value(obj, "id"), # Set the ID from the REST response
creation_context=creation_context, # Populate created/modified metadata from systemData
environment=environment_id, # Use the environment ID from API
request_settings=request_settings_obj, # Use proper OnlineRequestSettings object or None
liveness_probe=liveness_probe_obj, # Use proper ProbeSettings object or None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------

import datetime
from unittest.mock import Mock, patch

import pytest
Expand Down Expand Up @@ -295,3 +296,91 @@ 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_populates_creation_context_flattened(self):
"""Regression test for bug #5402673.

The deployment-template API returns flattened ``createdTime`` / ``modifiedTime`` /
``createdBy`` fields (not a nested ``systemData`` block), so ``creation_context``
must surface them to stay consistent with Model and Environment.
"""
from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401 import models as rest_models

rest = rest_models.DeploymentTemplate(
{
"name": "test",
"version": "5",
"createdTime": "2026-06-27T20:00:15.2050594+00:00",
"modifiedTime": "2026-06-27T21:31:55.5333635+00:00",
"createdBy": {"userName": "azure-huggingface"},
}
)

template = DeploymentTemplate._from_rest_object(rest)

assert template.creation_context is not None
assert template.creation_context.created_at is not None
assert template.creation_context.last_modified_at is not None
assert template.creation_context.created_by == "azure-huggingface"

def test_deployment_template_from_rest_object_creation_context_nested_in_properties(self):
"""Regression test for the list() shape of bug #5402673.

In the list() response, createdTime / modifiedTime / createdBy are nested under
``properties`` (not at the top level like get()). creation_context must still be
populated from there.
"""
rest = Mock(spec=[])
rest.name = "test"
rest.id = None
rest.system_data = None
rest.properties = {
"name": "test",
"version": "5",
"createdTime": "2026-06-27T20:00:15.2050594+00:00",
"modifiedTime": "2026-06-27T21:31:55.5333635+00:00",
# list() returns createdBy as a stringified dict (str), not a real dict.
"createdBy": "{'userObjectId': '00000000-0000-0000-0000-000000000000', 'userName': 'azure-huggingface'}",
}

template = DeploymentTemplate._from_rest_object(rest)

assert template.creation_context is not None
assert template.creation_context.created_at is not None
assert template.creation_context.last_modified_at is not None
assert template.creation_context.created_by == "azure-huggingface"

def test_deployment_template_from_rest_object_populates_creation_context_system_data(self):
"""creation_context is also populated from a nested systemData block when present."""
system_data = Mock(spec=[])
system_data.created_at = datetime.datetime(2026, 1, 1, 12, 0, 0)
system_data.created_by = "creator@microsoft.com"
system_data.created_by_type = "User"
system_data.last_modified_at = datetime.datetime(2026, 1, 2, 8, 30, 0)
system_data.last_modified_by = "modifier@microsoft.com"
system_data.last_modified_by_type = "User"

mock_rest = Mock(spec=[])
mock_rest.properties = {"name": "test", "version": "1"}
mock_rest.name = "test"
mock_rest.id = None
mock_rest.system_data = system_data

template = DeploymentTemplate._from_rest_object(mock_rest)

assert template.creation_context is not None
assert template.creation_context.created_at == datetime.datetime(2026, 1, 1, 12, 0, 0)
assert template.creation_context.created_by == "creator@microsoft.com"
assert template.creation_context.last_modified_at == datetime.datetime(2026, 1, 2, 8, 30, 0)

def test_deployment_template_from_rest_object_creation_context_none_when_absent(self):
"""creation_context should be None when the response carries no creation metadata."""
mock_rest = Mock(spec=[])
mock_rest.properties = {"name": "test", "version": "1"}
mock_rest.name = "test"
mock_rest.id = None
# Intentionally no systemData / createdTime / modifiedTime / createdBy set.

template = DeploymentTemplate._from_rest_object(mock_rest)

assert template.creation_context is None
Loading