diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index 8b326030a09b..94ba8a8ee042 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -8,6 +8,7 @@ - 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()`. +- 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. ### Other Changes diff --git a/sdk/ml/azure-ai-ml/api.md b/sdk/ml/azure-ai-ml/api.md index 576a38619750..9be2ca290999 100644 --- a/sdk/ml/azure-ai-ml/api.md +++ b/sdk/ml/azure-ai-ml/api.md @@ -10419,6 +10419,7 @@ namespace azure.ai.ml.operations self, name: str, version: Optional[str] = None, + label: Optional[str] = None, **kwargs: Any ) -> DeploymentTemplate: ... diff --git a/sdk/ml/azure-ai-ml/api.metadata.yml b/sdk/ml/azure-ai-ml/api.metadata.yml index 9549c7c79293..14c9e4ed800a 100644 --- a/sdk/ml/azure-ai-ml/api.metadata.yml +++ b/sdk/ml/azure-ai-ml/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 7262033cadec8b494dfa9cca72d97a9dad84e1e465ce8c455782d9fab275a81f +apiMdSha256: 81371e37ca26ea3c761bdb2104b7a0b89a0026216a97510bc16f533cf936e2d9 parserVersion: 0.3.28 pythonVersion: 3.12.10 diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_deployment_template_operations.py b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_deployment_template_operations.py index b5a75bcc27ea..ed2a35a28956 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_deployment_template_operations.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/operations/_deployment_template_operations.py @@ -318,33 +318,81 @@ def list( ), ) + def _resolve_latest_version(self, name: str) -> str: + """Resolve the latest version of a deployment template by name. + + The deployment-template service does not expose a ``latest`` label and its list endpoint + has no server-side ordering, so the latest version is resolved client-side by enumerating + the available (active) versions and returning the highest one. + + :param name: Name of the deployment template. + :type name: str + :return: The highest available version. + :rtype: str + :raises: ~azure.core.exceptions.ResourceNotFoundError if no versions exist for the name. + """ + versions = [template.version for template in self.list(name=name) if template.version is not None] + if not versions: + raise ResourceNotFoundError(f"DeploymentTemplate {name} not found: no versions available.") + + def _version_sort_key(version: str) -> tuple: + # Numeric versions sort above (and among) non-numeric ones so the highest number wins, + # while non-numeric versions still resolve deterministically via string comparison. + try: + return (1, int(version)) + except (TypeError, ValueError): + return (0, str(version)) + + return max(versions, key=_version_sort_key) + @distributed_trace @monitor_with_telemetry_mixin(ops_logger, "DeploymentTemplate.Get", ActivityType.PUBLICAPI) @experimental - def get(self, name: str, version: Optional[str] = None, **kwargs: Any) -> DeploymentTemplate: - """Get a deployment template by name and version. + def get( + self, + name: str, + version: Optional[str] = None, + label: Optional[str] = None, + **kwargs: Any, + ) -> DeploymentTemplate: + """Get a deployment template by name and version (or label). :param name: Name of the deployment template. :type name: str - :param version: Version of the deployment template. If not provided, gets the latest version. + :param version: Version of the deployment template. If neither ``version`` nor ``label`` is + provided, the latest version is returned. :type version: Optional[str] + :param label: Label of the deployment template. Only the ``latest`` label is supported, which + resolves to the latest version. Cannot be used together with ``version``. + :type label: Optional[str] :return: DeploymentTemplate object. :rtype: ~azure.ai.ml.entities.DeploymentTemplate :raises: ~azure.core.exceptions.ResourceNotFoundError if deployment template not found. """ - version = version or "latest" + if version and label: + raise ValueError("Cannot specify both version and label.") + + if label is not None and label != "latest": + raise ResourceNotFoundError( + f"DeploymentTemplate {name} with label '{label}' not found. Only the 'latest' label is supported." + ) + + # No explicit version (or label='latest') -> resolve the latest version client-side. + resolved_version = version or self._resolve_latest_version(name) try: result = self._service_client.deployment_templates.get( registry_name=self._operation_scope.registry_name, name=name, - version=version, + version=resolved_version, **kwargs, ) return DeploymentTemplate._from_rest_object(result) + except ResourceNotFoundError: + raise except Exception as e: module_logger.debug("DeploymentTemplate get operation failed: %s", e) - raise ResourceNotFoundError(f"DeploymentTemplate {name}:{version} not found") from e + raise ResourceNotFoundError(f"DeploymentTemplate {name}:{resolved_version} not found") from e @distributed_trace @monitor_with_telemetry_mixin(ops_logger, "DeploymentTemplate.CreateOrUpdate", ActivityType.PUBLICAPI) @@ -389,7 +437,7 @@ def delete(self, name: str, version: Optional[str] = None, **kwargs: Any) -> Non :param version: Version of the deployment template to delete. If not provided, deletes the latest version. :type version: Optional[str] """ - version = version or "latest" + version = version or self._resolve_latest_version(name) try: self._service_client.deployment_templates.delete( diff --git a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template_operations.py b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template_operations.py index 2eeb73a875f9..62e68fe8573c 100644 --- a/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template_operations.py +++ b/sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template_operations.py @@ -243,7 +243,7 @@ def test_get_deployment_template_not_found(self, deployment_template_ops): ) with pytest.raises(HttpResponseError): - deployment_template_ops.get("nonexistent-template") + deployment_template_ops.get("nonexistent-template", "1.0") def test_list_deployment_templates(self, deployment_template_ops, sample_rest_template): """Test list operation for deployment templates.""" @@ -331,7 +331,74 @@ def test_delete_deployment_template_not_found(self, deployment_template_ops): deployment_template_ops._operation_scope.workspace_name = "test-workspace" with pytest.raises(HttpResponseError): - deployment_template_ops.delete("nonexistent-template") + deployment_template_ops.delete("nonexistent-template", "1.0") + + def test_get_no_version_resolves_latest(self, deployment_template_ops, sample_rest_template): + """get() without a version resolves the highest available version instead of sending 'latest'.""" + deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template) + # list() returns versions out of order; the highest (5) must be selected. + deployment_template_ops.list = Mock( + return_value=[ + DeploymentTemplate(name="test-template", version="2"), + DeploymentTemplate(name="test-template", version="5"), + DeploymentTemplate(name="test-template", version="3"), + ] + ) + + result = deployment_template_ops.get("test-template") + + deployment_template_ops.list.assert_called_once_with(name="test-template") + call_args = deployment_template_ops._service_client.deployment_templates.get.call_args + assert call_args[1]["version"] == "5" + assert isinstance(result, DeploymentTemplate) + + def test_get_label_latest_resolves_latest(self, deployment_template_ops, sample_rest_template): + """get(label='latest') resolves to the highest available version.""" + deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template) + deployment_template_ops.list = Mock( + return_value=[ + DeploymentTemplate(name="test-template", version="1"), + DeploymentTemplate(name="test-template", version="4"), + ] + ) + + result = deployment_template_ops.get("test-template", label="latest") + + call_args = deployment_template_ops._service_client.deployment_templates.get.call_args + assert call_args[1]["version"] == "4" + assert isinstance(result, DeploymentTemplate) + + def test_get_version_and_label_raises(self, deployment_template_ops): + """get() with both version and label is rejected.""" + with pytest.raises(ValueError): + deployment_template_ops.get("test-template", version="1", label="latest") + + def test_get_unsupported_label_raises(self, deployment_template_ops): + """Only the 'latest' label is supported; other labels raise ResourceNotFoundError.""" + with pytest.raises(ResourceNotFoundError): + deployment_template_ops.get("test-template", label="production") + + def test_get_no_versions_raises_not_found(self, deployment_template_ops): + """get() without a version raises when no versions exist to resolve.""" + deployment_template_ops.list = Mock(return_value=[]) + + with pytest.raises(ResourceNotFoundError): + deployment_template_ops.get("test-template") + + def test_delete_no_version_resolves_latest(self, deployment_template_ops): + """delete() without a version resolves the highest available version instead of sending 'latest'.""" + deployment_template_ops._service_client.deployment_templates.delete = Mock(return_value=None) + deployment_template_ops.list = Mock( + return_value=[ + DeploymentTemplate(name="test-template", version="2"), + DeploymentTemplate(name="test-template", version="5"), + ] + ) + + deployment_template_ops.delete("test-template") + + call_args = deployment_template_ops._service_client.deployment_templates.delete.call_args + assert call_args[1]["version"] == "5" def test_create_or_update_with_none_input(self, deployment_template_ops): """Test create_or_update with None input.""" @@ -416,7 +483,7 @@ def test_get_with_invalid_name(self, deployment_template_ops): ) with pytest.raises(HttpResponseError): - deployment_template_ops.get("") + deployment_template_ops.get("", "1.0") def test_delete_with_invalid_name(self, deployment_template_ops): """Test delete operation with invalid template name.""" @@ -434,7 +501,7 @@ def test_delete_with_invalid_name(self, deployment_template_ops): deployment_template_ops._operation_scope.workspace_name = "test-workspace" with pytest.raises(HttpResponseError): - deployment_template_ops.delete("") + deployment_template_ops.delete("", "1.0") @patch("azure.ai.ml.entities._load_functions.load_deployment_template") def test_create_or_update_yaml_file_not_found(self, mock_load, deployment_template_ops): @@ -813,27 +880,39 @@ def test_create_or_update_with_invalid_type(self, deployment_template_ops): deployment_template_ops.create_or_update({"name": "test"}) def test_delete_default_version(self, deployment_template_ops): - """Test delete operation with default version.""" + """Test delete resolves the latest version when no version is provided.""" deployment_template_ops._service_client.deployment_templates.delete = Mock() + deployment_template_ops.list = Mock( + return_value=[ + DeploymentTemplate(name="test-template", version="1"), + DeploymentTemplate(name="test-template", version="3"), + ] + ) deployment_template_ops._operation_scope.subscription_id = "test-sub" deployment_template_ops._operation_scope.resource_group_name = "test-rg" deployment_template_ops._operation_scope.registry_name = "test-registry" deployment_template_ops.delete("test-template") - # Verify version defaults to "latest" + # Verify version defaults to the highest available version (not the literal string "latest") call_args = deployment_template_ops._service_client.deployment_templates.delete.call_args - assert call_args[1]["version"] == "latest" + assert call_args[1]["version"] == "3" def test_get_default_version(self, deployment_template_ops, sample_rest_template): - """Test get operation with default version.""" + """Test get resolves the latest version when no version is provided.""" deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template) + deployment_template_ops.list = Mock( + return_value=[ + DeploymentTemplate(name="test-template", version="1"), + DeploymentTemplate(name="test-template", version="3"), + ] + ) deployment_template_ops._operation_scope.subscription_id = "test-sub" deployment_template_ops._operation_scope.resource_group_name = "test-rg" deployment_template_ops._operation_scope.registry_name = "test-registry" deployment_template_ops.get("test-template") - # Verify version defaults to "latest" + # Verify version defaults to the highest available version (not the literal string "latest") call_args = deployment_template_ops._service_client.deployment_templates.get.call_args - assert call_args[1]["version"] == "latest" + assert call_args[1]["version"] == "3"