diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index 9d31633f75d3..b30945c53062 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 `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 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..20b9d980fe82 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 @@ -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 @@ -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", @@ -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 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..c5c0fd88134a 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 @@ -2,6 +2,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- +import datetime from unittest.mock import Mock, patch import pytest @@ -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