Skip to content

Commit 7ed6bad

Browse files
[azure-ai-ml] Fix deployment_templates.get(name) resolving latest version (Bug 5402671) (#47975)
* Fix DeploymentTemplate get()/delete() sending literal "latest" as version deployment_templates.get(name) with no version sent the literal "latest" in the version slot, which the service treats as a (nonexistent) label rather than "highest version", causing a 404 (DeploymentTemplate {name}:latest not found). The latest version is now resolved client-side by listing the available versions and selecting the highest (the service exposes no "latest" label and no server-side ordering). get() also accepts a `label` keyword (label="latest" resolves to the latest version) mirroring models.get(), so a Model allowedDeploymentTemplates .../labels/latest reference can be resolved; unsupported labels raise a clear error. delete(name) resolves the latest version the same way. Bug 5402671 * Regenerate api.md and api.metadata.yml for new DeploymentTemplate.get label parameter Adds the `label: Optional[str] = None` parameter to the DeploymentTemplate.get signature in the API surface snapshot and updates apiMdSha256 accordingly, so the API.md consistency CI check passes.
1 parent 02456a3 commit 7ed6bad

5 files changed

Lines changed: 147 additions & 18 deletions

File tree

sdk/ml/azure-ai-ml/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- 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.
99
- 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`.
1010
- 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()`.
11+
- 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.
1112

1213
### Other Changes
1314

sdk/ml/azure-ai-ml/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10419,6 +10419,7 @@ namespace azure.ai.ml.operations
1041910419
self,
1042010420
name: str,
1042110421
version: Optional[str] = None,
10422+
label: Optional[str] = None,
1042210423
**kwargs: Any
1042310424
) -> DeploymentTemplate: ...
1042410425

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
apiMdSha256: 7262033cadec8b494dfa9cca72d97a9dad84e1e465ce8c455782d9fab275a81f
1+
apiMdSha256: 81371e37ca26ea3c761bdb2104b7a0b89a0026216a97510bc16f533cf936e2d9
22
parserVersion: 0.3.28
33
pythonVersion: 3.12.10

sdk/ml/azure-ai-ml/azure/ai/ml/operations/_deployment_template_operations.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,33 +318,81 @@ def list(
318318
),
319319
)
320320

321+
def _resolve_latest_version(self, name: str) -> str:
322+
"""Resolve the latest version of a deployment template by name.
323+
324+
The deployment-template service does not expose a ``latest`` label and its list endpoint
325+
has no server-side ordering, so the latest version is resolved client-side by enumerating
326+
the available (active) versions and returning the highest one.
327+
328+
:param name: Name of the deployment template.
329+
:type name: str
330+
:return: The highest available version.
331+
:rtype: str
332+
:raises: ~azure.core.exceptions.ResourceNotFoundError if no versions exist for the name.
333+
"""
334+
versions = [template.version for template in self.list(name=name) if template.version is not None]
335+
if not versions:
336+
raise ResourceNotFoundError(f"DeploymentTemplate {name} not found: no versions available.")
337+
338+
def _version_sort_key(version: str) -> tuple:
339+
# Numeric versions sort above (and among) non-numeric ones so the highest number wins,
340+
# while non-numeric versions still resolve deterministically via string comparison.
341+
try:
342+
return (1, int(version))
343+
except (TypeError, ValueError):
344+
return (0, str(version))
345+
346+
return max(versions, key=_version_sort_key)
347+
321348
@distributed_trace
322349
@monitor_with_telemetry_mixin(ops_logger, "DeploymentTemplate.Get", ActivityType.PUBLICAPI)
323350
@experimental
324-
def get(self, name: str, version: Optional[str] = None, **kwargs: Any) -> DeploymentTemplate:
325-
"""Get a deployment template by name and version.
351+
def get(
352+
self,
353+
name: str,
354+
version: Optional[str] = None,
355+
label: Optional[str] = None,
356+
**kwargs: Any,
357+
) -> DeploymentTemplate:
358+
"""Get a deployment template by name and version (or label).
326359
327360
:param name: Name of the deployment template.
328361
:type name: str
329-
:param version: Version of the deployment template. If not provided, gets the latest version.
362+
:param version: Version of the deployment template. If neither ``version`` nor ``label`` is
363+
provided, the latest version is returned.
330364
:type version: Optional[str]
365+
:param label: Label of the deployment template. Only the ``latest`` label is supported, which
366+
resolves to the latest version. Cannot be used together with ``version``.
367+
:type label: Optional[str]
331368
:return: DeploymentTemplate object.
332369
:rtype: ~azure.ai.ml.entities.DeploymentTemplate
333370
:raises: ~azure.core.exceptions.ResourceNotFoundError if deployment template not found.
334371
"""
335-
version = version or "latest"
372+
if version and label:
373+
raise ValueError("Cannot specify both version and label.")
374+
375+
if label is not None and label != "latest":
376+
raise ResourceNotFoundError(
377+
f"DeploymentTemplate {name} with label '{label}' not found. Only the 'latest' label is supported."
378+
)
379+
380+
# No explicit version (or label='latest') -> resolve the latest version client-side.
381+
resolved_version = version or self._resolve_latest_version(name)
336382

337383
try:
338384
result = self._service_client.deployment_templates.get(
339385
registry_name=self._operation_scope.registry_name,
340386
name=name,
341-
version=version,
387+
version=resolved_version,
342388
**kwargs,
343389
)
344390
return DeploymentTemplate._from_rest_object(result)
391+
except ResourceNotFoundError:
392+
raise
345393
except Exception as e:
346394
module_logger.debug("DeploymentTemplate get operation failed: %s", e)
347-
raise ResourceNotFoundError(f"DeploymentTemplate {name}:{version} not found") from e
395+
raise ResourceNotFoundError(f"DeploymentTemplate {name}:{resolved_version} not found") from e
348396

349397
@distributed_trace
350398
@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
389437
:param version: Version of the deployment template to delete. If not provided, deletes the latest version.
390438
:type version: Optional[str]
391439
"""
392-
version = version or "latest"
440+
version = version or self._resolve_latest_version(name)
393441

394442
try:
395443
self._service_client.deployment_templates.delete(

sdk/ml/azure-ai-ml/tests/deployment_template/unittests/test_deployment_template_operations.py

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def test_get_deployment_template_not_found(self, deployment_template_ops):
243243
)
244244

245245
with pytest.raises(HttpResponseError):
246-
deployment_template_ops.get("nonexistent-template")
246+
deployment_template_ops.get("nonexistent-template", "1.0")
247247

248248
def test_list_deployment_templates(self, deployment_template_ops, sample_rest_template):
249249
"""Test list operation for deployment templates."""
@@ -331,7 +331,74 @@ def test_delete_deployment_template_not_found(self, deployment_template_ops):
331331
deployment_template_ops._operation_scope.workspace_name = "test-workspace"
332332

333333
with pytest.raises(HttpResponseError):
334-
deployment_template_ops.delete("nonexistent-template")
334+
deployment_template_ops.delete("nonexistent-template", "1.0")
335+
336+
def test_get_no_version_resolves_latest(self, deployment_template_ops, sample_rest_template):
337+
"""get() without a version resolves the highest available version instead of sending 'latest'."""
338+
deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template)
339+
# list() returns versions out of order; the highest (5) must be selected.
340+
deployment_template_ops.list = Mock(
341+
return_value=[
342+
DeploymentTemplate(name="test-template", version="2"),
343+
DeploymentTemplate(name="test-template", version="5"),
344+
DeploymentTemplate(name="test-template", version="3"),
345+
]
346+
)
347+
348+
result = deployment_template_ops.get("test-template")
349+
350+
deployment_template_ops.list.assert_called_once_with(name="test-template")
351+
call_args = deployment_template_ops._service_client.deployment_templates.get.call_args
352+
assert call_args[1]["version"] == "5"
353+
assert isinstance(result, DeploymentTemplate)
354+
355+
def test_get_label_latest_resolves_latest(self, deployment_template_ops, sample_rest_template):
356+
"""get(label='latest') resolves to the highest available version."""
357+
deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template)
358+
deployment_template_ops.list = Mock(
359+
return_value=[
360+
DeploymentTemplate(name="test-template", version="1"),
361+
DeploymentTemplate(name="test-template", version="4"),
362+
]
363+
)
364+
365+
result = deployment_template_ops.get("test-template", label="latest")
366+
367+
call_args = deployment_template_ops._service_client.deployment_templates.get.call_args
368+
assert call_args[1]["version"] == "4"
369+
assert isinstance(result, DeploymentTemplate)
370+
371+
def test_get_version_and_label_raises(self, deployment_template_ops):
372+
"""get() with both version and label is rejected."""
373+
with pytest.raises(ValueError):
374+
deployment_template_ops.get("test-template", version="1", label="latest")
375+
376+
def test_get_unsupported_label_raises(self, deployment_template_ops):
377+
"""Only the 'latest' label is supported; other labels raise ResourceNotFoundError."""
378+
with pytest.raises(ResourceNotFoundError):
379+
deployment_template_ops.get("test-template", label="production")
380+
381+
def test_get_no_versions_raises_not_found(self, deployment_template_ops):
382+
"""get() without a version raises when no versions exist to resolve."""
383+
deployment_template_ops.list = Mock(return_value=[])
384+
385+
with pytest.raises(ResourceNotFoundError):
386+
deployment_template_ops.get("test-template")
387+
388+
def test_delete_no_version_resolves_latest(self, deployment_template_ops):
389+
"""delete() without a version resolves the highest available version instead of sending 'latest'."""
390+
deployment_template_ops._service_client.deployment_templates.delete = Mock(return_value=None)
391+
deployment_template_ops.list = Mock(
392+
return_value=[
393+
DeploymentTemplate(name="test-template", version="2"),
394+
DeploymentTemplate(name="test-template", version="5"),
395+
]
396+
)
397+
398+
deployment_template_ops.delete("test-template")
399+
400+
call_args = deployment_template_ops._service_client.deployment_templates.delete.call_args
401+
assert call_args[1]["version"] == "5"
335402

336403
def test_create_or_update_with_none_input(self, deployment_template_ops):
337404
"""Test create_or_update with None input."""
@@ -416,7 +483,7 @@ def test_get_with_invalid_name(self, deployment_template_ops):
416483
)
417484

418485
with pytest.raises(HttpResponseError):
419-
deployment_template_ops.get("")
486+
deployment_template_ops.get("", "1.0")
420487

421488
def test_delete_with_invalid_name(self, deployment_template_ops):
422489
"""Test delete operation with invalid template name."""
@@ -434,7 +501,7 @@ def test_delete_with_invalid_name(self, deployment_template_ops):
434501
deployment_template_ops._operation_scope.workspace_name = "test-workspace"
435502

436503
with pytest.raises(HttpResponseError):
437-
deployment_template_ops.delete("")
504+
deployment_template_ops.delete("", "1.0")
438505

439506
@patch("azure.ai.ml.entities._load_functions.load_deployment_template")
440507
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):
813880
deployment_template_ops.create_or_update({"name": "test"})
814881

815882
def test_delete_default_version(self, deployment_template_ops):
816-
"""Test delete operation with default version."""
883+
"""Test delete resolves the latest version when no version is provided."""
817884
deployment_template_ops._service_client.deployment_templates.delete = Mock()
885+
deployment_template_ops.list = Mock(
886+
return_value=[
887+
DeploymentTemplate(name="test-template", version="1"),
888+
DeploymentTemplate(name="test-template", version="3"),
889+
]
890+
)
818891
deployment_template_ops._operation_scope.subscription_id = "test-sub"
819892
deployment_template_ops._operation_scope.resource_group_name = "test-rg"
820893
deployment_template_ops._operation_scope.registry_name = "test-registry"
821894

822895
deployment_template_ops.delete("test-template")
823896

824-
# Verify version defaults to "latest"
897+
# Verify version defaults to the highest available version (not the literal string "latest")
825898
call_args = deployment_template_ops._service_client.deployment_templates.delete.call_args
826-
assert call_args[1]["version"] == "latest"
899+
assert call_args[1]["version"] == "3"
827900

828901
def test_get_default_version(self, deployment_template_ops, sample_rest_template):
829-
"""Test get operation with default version."""
902+
"""Test get resolves the latest version when no version is provided."""
830903
deployment_template_ops._service_client.deployment_templates.get = Mock(return_value=sample_rest_template)
904+
deployment_template_ops.list = Mock(
905+
return_value=[
906+
DeploymentTemplate(name="test-template", version="1"),
907+
DeploymentTemplate(name="test-template", version="3"),
908+
]
909+
)
831910
deployment_template_ops._operation_scope.subscription_id = "test-sub"
832911
deployment_template_ops._operation_scope.resource_group_name = "test-rg"
833912
deployment_template_ops._operation_scope.registry_name = "test-registry"
834913

835914
deployment_template_ops.get("test-template")
836915

837-
# Verify version defaults to "latest"
916+
# Verify version defaults to the highest available version (not the literal string "latest")
838917
call_args = deployment_template_ops._service_client.deployment_templates.get.call_args
839-
assert call_args[1]["version"] == "latest"
918+
assert call_args[1]["version"] == "3"

0 commit comments

Comments
 (0)