From 72a809b4c97896e467fbfc7cc2777d09efbac3fb Mon Sep 17 00:00:00 2001 From: Swarup Chanda Date: Fri, 10 Jul 2026 17:47:02 +0530 Subject: [PATCH] Fix models.get(name, label="latest") dropping deployment-template references models.get(name, label="latest") returned a Model whose default_deployment_template and allowed_deployment_templates references had asset_id=None, while models.get(name, version=) populated them. Label resolution goes through the version list endpoint (top=1), whose items omit the deployment-template references; the explicit version path uses the get endpoint, which includes them. After resolving the label to a concrete version, the resolved version is now re-fetched through the get endpoint so the label path hydrates these references identically to the version path. Bug 5423568 --- sdk/ml/azure-ai-ml/CHANGELOG.md | 1 + .../ai/ml/operations/_model_operations.py | 9 +- .../model/unittests/test_model_operations.py | 87 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index 94ba8a8ee042..dc4b9323d5a8 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -9,6 +9,7 @@ - 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()`. - Fixed `deployment_templates.get(name)` failing with a 404 (`DeploymentTemplate {name}:latest not found`) when no version was supplied, because the literal string `"latest"` was sent as the version. The latest version is now resolved client-side (the service exposes no `latest` label and no server-side ordering), and `get()` accepts a `label` keyword (`label="latest"` resolves to the latest version) mirroring `models.get()`. `delete(name)` resolves the latest version the same way. +- Fixed `models.get(name, label="latest")` returning a `Model` whose `default_deployment_template` / `allowed_deployment_templates` references had `asset_id=None`. Label resolution goes through the version list endpoint (`top=1`), whose items omit the deployment-template references; the resolved version is now re-fetched through the get endpoint, so the label path hydrates these references identically to the explicit `version=` path. ### Other Changes diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_model_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_model_operations.py index 2d6a04ce9afc..73d8f454a9a6 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_model_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_model_operations.py @@ -378,7 +378,14 @@ def get(self, name: str, version: Optional[str] = None, label: Optional[str] = N ) if label: - return _resolve_label_to_asset(self, name, label) + resolved_model = _resolve_label_to_asset(self, name, label) + # Label resolution goes through the version LIST endpoint (top=1), whose items omit + # deployment-template references (default_deployment_template / allowed_deployment_templates). + # Re-fetch the resolved version through the GET endpoint so the label path hydrates those + # references identically to the explicit version= path. See bug 5423568. + if resolved_model.version is not None: + return Model._from_rest_object(self._get(name, resolved_model.version)) + return resolved_model if not version: msg = "Must provide either version or label" diff --git a/sdk/ml/azure-ai-ml/tests/model/unittests/test_model_operations.py b/sdk/ml/azure-ai-ml/tests/model/unittests/test_model_operations.py index 37761ae5c842..ab32c40dfa21 100644 --- a/sdk/ml/azure-ai-ml/tests/model/unittests/test_model_operations.py +++ b/sdk/ml/azure-ai-ml/tests/model/unittests/test_model_operations.py @@ -12,6 +12,10 @@ ModelVersionProperties as ModelVersionDetails, ) from azure.ai.ml._scope_dependent_operations import OperationConfig, OperationScope +from azure.ai.ml._restclient.v2021_10_01_dataplanepreview.models import ( + ModelVersionData as RegistryModelVersionData, + ModelVersionDetails as RegistryModelVersionDetails, +) from azure.ai.ml.entities._assets import Model from azure.ai.ml.entities._assets._artifacts.artifact import ArtifactStorageInfo from azure.ai.ml.exceptions import ErrorTarget, ValidationException @@ -64,6 +68,49 @@ def mock_model_operation_reg( ) +def _make_registry_model_rest_object( + version: str, + default_dt_asset_id: Optional[str] = None, + allowed_dt_asset_ids: Optional[list] = None, +) -> Mock: + """Build a registry ModelVersionData mock mirroring the real GET/LIST response shapes. + + The GET response carries default_deployment_template / allowed_deployment_templates; the + LIST (top=1) response used for label resolution omits them, which is what drops the + references on the label path (bug 5423568). + """ + rest_properties = Mock(spec=RegistryModelVersionDetails) + rest_properties.description = "Test model" + rest_properties.tags = {} + rest_properties.properties = {} + rest_properties.flavors = {} + rest_properties.model_uri = "azureml://locations/test/artifacts/model" + rest_properties.model_type = "custom_model" + rest_properties.stage = None + rest_properties.job_name = None + rest_properties.intellectual_property = None + rest_properties.system_metadata = None + rest_properties.default_deployment_template = {"asset_id": default_dt_asset_id} if default_dt_asset_id else None + rest_properties.allowed_deployment_templates = ( + [{"asset_id": asset_id} for asset_id in allowed_dt_asset_ids] if allowed_dt_asset_ids else None + ) + + rest_object = Mock(spec=RegistryModelVersionData) + rest_object.id = ( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.MachineLearningServices" + f"/workspaces/ws/models/test-model/versions/{version}" + ) + rest_object.properties = rest_properties + + system_data = Mock() + system_data.created_by = "test_user" + system_data.created_at = None + system_data.last_modified_by = None + system_data.last_modified_at = None + rest_object.system_data = system_data + return rest_object + + @pytest.mark.unittest @pytest.mark.production_experiences_test class TestModelOperations: @@ -172,6 +219,46 @@ def test_get_no_version(self, mock_model_operation: ModelOperations) -> None: with pytest.raises(Exception): mock_model_operation.get(name=name) + def test_get_with_label_rehydrates_deployment_template_references( + self, mock_model_operation_reg: ModelOperations + ) -> None: + """Bug 5423568: models.get(name, label='latest') must hydrate deployment-template + references identically to the explicit version= path. + + Label resolution goes through the version LIST endpoint (top=1), whose items omit + default_deployment_template / allowed_deployment_templates. The fix re-fetches the + resolved version through the GET endpoint, which carries those references. + """ + default_dt = "azureml://registries/azure-huggingface/deploymenttemplates/dt/versions/5" + allowed_dt = "azureml://registries/azure-huggingface/deploymenttemplates/dt/labels/latest" + + # LIST shape (used by label resolution) -> deployment-template references dropped. + list_pager = Mock() + list_pager.next.return_value = _make_registry_model_rest_object(version="5") + mock_model_operation_reg._model_versions_operation.list.return_value = list_pager + + # GET shape (used by the re-fetch) -> deployment-template references populated. + mock_model_operation_reg._model_versions_operation.get.return_value = _make_registry_model_rest_object( + version="5", default_dt_asset_id=default_dt, allowed_dt_asset_ids=[allowed_dt] + ) + + result = mock_model_operation_reg.get(name="test-model", label="latest") + + # The resolved version was re-fetched through GET (not returned straight from the LIST shape). + mock_model_operation_reg._model_versions_operation.get.assert_called_once_with( + name="test-model", + version="5", + registry_name=mock_model_operation_reg._registry_name, + **mock_model_operation_reg._scope_kwargs, + ) + # Deployment-template references are hydrated, matching the explicit version= path. + assert result.version == "5" + assert result.default_deployment_template is not None + assert result.default_deployment_template.asset_id == default_dt + assert result.allowed_deployment_templates is not None + assert len(result.allowed_deployment_templates) == 1 + assert result.allowed_deployment_templates[0].asset_id == allowed_dt + @patch.object(Model, "_from_rest_object", new=Mock()) @patch.object(Model, "_from_container_rest_object", new=Mock()) def test_list(self, mock_model_operation: ModelOperations) -> None: