Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/ml/azure-ai-ml/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sdk/ml/azure-ai-ml/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10419,6 +10419,7 @@ namespace azure.ai.ml.operations
self,
name: str,
version: Optional[str] = None,
label: Optional[str] = None,
**kwargs: Any
) -> DeploymentTemplate: ...

Expand Down
2 changes: 1 addition & 1 deletion sdk/ml/azure-ai-ml/api.metadata.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
apiMdSha256: 7262033cadec8b494dfa9cca72d97a9dad84e1e465ce8c455782d9fab275a81f
apiMdSha256: 81371e37ca26ea3c761bdb2104b7a0b89a0026216a97510bc16f533cf936e2d9
parserVersion: 0.3.28
pythonVersion: 3.12.10
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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"
Loading