From 87fe9e48bd4130393ff24581256e16d591bb6ed5 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Tue, 7 Jul 2026 18:11:11 +0530 Subject: [PATCH 1/3] [azure-ai-ml] Populate DeploymentTemplate.creation_context from systemData DeploymentTemplate._from_rest_object never deserialized the service's systemData, so creation_context was always None, unlike Model and Environment which populate it. Read system_data from the REST object and pass creation_context to the entity, and add regression tests. Fixes bug #5402673. --- sdk/ml/azure-ai-ml/CHANGELOG.md | 1 + .../_deployment/deployment_template.py | 8 ++++ .../unittests/test_deployment_template.py | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index 9d31633f75d3..d8ae07db7991 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 identities returned by the service in `systemData` are now deserialized into `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..895605a20142 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,7 @@ 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 @experimental @@ -486,6 +487,12 @@ def get_value(source, key, default=None): except (ValueError, TypeError): scoring_port = None + # Populate the creation_context (created/modified timestamps and identities) from the + # service's systemData, consistent with how Model and Environment entities behave. + # The REST object exposes it as ``system_data``; raw dict payloads use ``systemData``. + system_data = get_value(obj, "system_data") or get_value(obj, "systemData") + creation_context = SystemData._from_rest_object(system_data) if system_data else None + template = cls( name=name or "unknown", version=version or "1.0", @@ -494,6 +501,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..88b348a80c11 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,46 @@ 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(self): + """_from_rest_object should populate creation_context from the service systemData. + + Regression test for bug #5402673, where DeploymentTemplate.creation_context was + always None even though Model and Environment populate it from systemData. + """ + created_at = datetime.datetime(2026, 1, 1, 12, 0, 0) + last_modified_at = datetime.datetime(2026, 1, 2, 8, 30, 0) + + system_data = Mock(spec=[]) + system_data.created_at = created_at + system_data.created_by = "creator@microsoft.com" + system_data.created_by_type = "User" + system_data.last_modified_at = last_modified_at + 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 == created_at + assert template.creation_context.created_by == "creator@microsoft.com" + assert template.creation_context.last_modified_at == last_modified_at + assert template.creation_context.last_modified_by == "modifier@microsoft.com" + + def test_deployment_template_from_rest_object_creation_context_none_when_absent(self): + """creation_context should be None when the REST object carries no systemData.""" + mock_rest = Mock(spec=[]) + mock_rest.properties = {"name": "test", "version": "1"} + mock_rest.name = "test" + mock_rest.id = None + # Intentionally no system_data / systemData attribute set. + + template = DeploymentTemplate._from_rest_object(mock_rest) + + assert template.creation_context is None From e84ad79bdba271e3f19389c186aee93fdfdbc9d0 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Tue, 7 Jul 2026 22:37:22 +0530 Subject: [PATCH 2/3] [azure-ai-ml] Read flattened createdTime/modifiedTime/createdBy for DeploymentTemplate.creation_context The deployment-template API returns creation metadata as flattened createdTime / modifiedTime / createdBy fields rather than a nested systemData block, so _from_rest_object was still producing creation_context=None against the live service. Read those flattened fields (with a systemData fallback), parse the timestamps to datetime, and update the unit tests to use the real REST model. Verified against the live azure-huggingface registry. Fixes bug #5402673. --- sdk/ml/azure-ai-ml/CHANGELOG.md | 2 +- .../_deployment/deployment_template.py | 69 +++++++++++++++++-- .../unittests/test_deployment_template.py | 46 +++++++++---- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index d8ae07db7991..b30945c53062 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -6,7 +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 identities returned by the service in `systemData` are now deserialized into `creation_context`, making `DeploymentTemplate` consistent with `Model` and `Environment`. +- 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 895605a20142..37bee2779e42 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 @@ -20,6 +20,47 @@ 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 + + @experimental class DeploymentTemplate(Resource, RestTranslatableMixin): # pylint: disable=too-many-instance-attributes """DeploymentTemplate entity for Azure ML deployments. @@ -487,11 +528,31 @@ def get_value(source, key, default=None): except (ValueError, TypeError): scoring_port = None - # Populate the creation_context (created/modified timestamps and identities) from the - # service's systemData, consistent with how Model and Environment entities behave. - # The REST object exposes it as ``system_data``; raw dict payloads use ``systemData``. + # 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, so support + # both shapes here. system_data = get_value(obj, "system_data") or get_value(obj, "systemData") - creation_context = SystemData._from_rest_object(system_data) if system_data else None + if system_data is not None: + creation_context = SystemData._from_rest_object(system_data) + else: + created_time = _read_wire_value(obj, "createdTime", "created_time", "createdAt", "created_at") + modified_time = _read_wire_value(obj, "modifiedTime", "modified_time", "lastModifiedAt", "last_modified_at") + created_by_raw = _read_wire_value(obj, "createdBy", "created_by") + if created_by_raw is None or isinstance(created_by_raw, str): + created_by = created_by_raw + else: + # The service may return createdBy as an object (e.g. {"userName": ...}). + created_by = _read_wire_value(created_by_raw, "userName", "user_name", "userObjectId", "user_object_id") + 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", 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 88b348a80c11..3e7010848fd3 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 @@ -297,20 +297,41 @@ 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(self): - """_from_rest_object should populate creation_context from the service systemData. + def test_deployment_template_from_rest_object_populates_creation_context_flattened(self): + """Regression test for bug #5402673. - Regression test for bug #5402673, where DeploymentTemplate.creation_context was - always None even though Model and Environment populate it from systemData. + 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. """ - created_at = datetime.datetime(2026, 1, 1, 12, 0, 0) - last_modified_at = datetime.datetime(2026, 1, 2, 8, 30, 0) + 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_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 = created_at + 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 = last_modified_at + 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" @@ -323,18 +344,17 @@ def test_deployment_template_from_rest_object_populates_creation_context(self): template = DeploymentTemplate._from_rest_object(mock_rest) assert template.creation_context is not None - assert template.creation_context.created_at == created_at + 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 == last_modified_at - assert template.creation_context.last_modified_by == "modifier@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 REST object carries no systemData.""" + """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 system_data / systemData attribute set. + # Intentionally no systemData / createdTime / modifiedTime / createdBy set. template = DeploymentTemplate._from_rest_object(mock_rest) From b32f832c37e7eea6b08b5728d04aa8802f01c410 Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Wed, 8 Jul 2026 02:25:00 +0530 Subject: [PATCH 3/3] [azure-ai-ml] Read DeploymentTemplate creation_context from properties (list shape) Address PR review: creation_context was only read from the top-level object, so it worked for get() but stayed None for list(), where createdTime / modifiedTime / createdBy are nested under properties. Read properties-first with an obj fallback, and parse the stringified createdBy that list() returns to surface the userName. Verified against the live azure-huggingface registry for both get() and list(). Adds a nested-in-properties regression test. --- .../_deployment/deployment_template.py | 46 +++++++++++++++---- .../unittests/test_deployment_template.py | 31 +++++++++++-- 2 files changed, 64 insertions(+), 13 deletions(-) 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 37bee2779e42..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 @@ -61,6 +61,30 @@ def _parse_iso_datetime(value: Any) -> Any: 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 class DeploymentTemplate(Resource, RestTranslatableMixin): # pylint: disable=too-many-instance-attributes """DeploymentTemplate entity for Azure ML deployments. @@ -531,20 +555,22 @@ def get_value(source, key, default=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, so support - # both shapes here. + # 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(obj, "createdTime", "created_time", "createdAt", "created_at") - modified_time = _read_wire_value(obj, "modifiedTime", "modified_time", "lastModifiedAt", "last_modified_at") - created_by_raw = _read_wire_value(obj, "createdBy", "created_by") - if created_by_raw is None or isinstance(created_by_raw, str): - created_by = created_by_raw - else: - # The service may return createdBy as an object (e.g. {"userName": ...}). - created_by = _read_wire_value(created_by_raw, "userName", "user_name", "userObjectId", "user_object_id") + 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), 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 3e7010848fd3..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 @@ -304,9 +304,7 @@ def test_deployment_template_from_rest_object_populates_creation_context_flatten ``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, - ) + from azure.ai.ml._restclient.azure_ai_assets_v2024_04_01.azureaiassetsv20240401 import models as rest_models rest = rest_models.DeploymentTemplate( { @@ -325,6 +323,33 @@ def test_deployment_template_from_rest_object_populates_creation_context_flatten 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=[])