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
19 changes: 19 additions & 0 deletions src/dstack/_internal/core/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ class FrozenCoreModel(CoreModel):
model_config = ConfigDict(frozen=True)


def pop_null_field(values: Any, *path: str) -> Any:
"""
Drop a field at `path` from the `values` nested dict if it's set to `null`.

Mutates and returns the same `values` object.
"""
if not isinstance(values, dict):
return values
node = values
for key in path[:-1]:
node = node.get(key)
if not isinstance(node, dict):
return values
field = path[-1]
if field in node and node[field] is None:
del node[field]
return values


T = TypeVar("T")

_type_adapters: dict[Any, TypeAdapter] = {}
Expand Down
20 changes: 17 additions & 3 deletions src/dstack/_internal/server/schemas/gateways.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from typing import Annotated, List, Optional
from typing import Annotated, Any, List, Optional

from pydantic import Field
from pydantic import Field, model_validator

from dstack._internal.core.models.common import CoreModel
from dstack._internal.core.models.common import CoreModel, pop_null_field
from dstack._internal.core.models.gateways import (
ApplyGatewayPlanInput,
GatewayConfiguration,
Expand All @@ -25,6 +25,12 @@ class GetGatewayRequest(CoreModel):
class GetGatewayPlanRequest(CoreModel):
spec: GatewaySpec

@model_validator(mode="before")
@classmethod
def _drop_null_router(cls, values: Any) -> Any:
# Compatibility with 0.20.27, 0.20.28, 0.20.29 clients
return pop_null_field(values, "spec", "configuration", "router")


class ApplyGatewayPlanRequest(CoreModel):
plan: ApplyGatewayPlanInput
Expand All @@ -35,6 +41,14 @@ class ApplyGatewayPlanRequest(CoreModel):
),
]

@model_validator(mode="before")
@classmethod
def _drop_null_router(cls, values: Any) -> Any:
# Compatibility with 0.20.27, 0.20.28, 0.20.29 clients
values = pop_null_field(values, "plan", "spec", "configuration", "router")
values = pop_null_field(values, "plan", "current_resource", "configuration", "router")
return values


class DeleteGatewaysRequest(CoreModel):
names: List[str]
Expand Down
38 changes: 37 additions & 1 deletion src/tests/_internal/core/models/test_common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from dstack._internal.core.models.common import EntityReference
from dstack._internal.core.models.common import EntityReference, pop_null_field


class TestEntityReferenceParse:
Expand All @@ -25,3 +25,39 @@ def test_valid(self, value, expected):
def test_invalid(self, value: str):
with pytest.raises(ValueError, match="Invalid entity reference"):
EntityReference.parse(value)


class TestPopNullField:
def test_drops_null_field_at_top_level(self):
values = {"router": None, "backend": "aws"}
assert pop_null_field(values, "router") == {"backend": "aws"}

def test_drops_null_field_in_nested_dict(self):
values = {"spec": {"configuration": {"router": None, "backend": "aws"}}}
assert pop_null_field(values, "spec", "configuration", "router") == {
"spec": {"configuration": {"backend": "aws"}}
}

def test_leaves_non_null_field_untouched(self):
values = {"configuration": {"router": "some-router"}}
assert pop_null_field(values, "configuration", "router") == {
"configuration": {"router": "some-router"}
}

def test_field_absent(self):
values = {"configuration": {"backend": "aws"}}
assert pop_null_field(values, "configuration", "router") == {
"configuration": {"backend": "aws"}
}

def test_intermediate_path_missing(self):
values = {"backend": "aws"}
assert pop_null_field(values, "configuration", "router") == {"backend": "aws"}

def test_intermediate_path_not_a_dict(self):
values = {"configuration": None}
assert pop_null_field(values, "configuration", "router") == {"configuration": None}

def test_top_level_not_a_dict(self):
values = "not-a-dict"
assert pop_null_field(values, "configuration", "router") == "not-a-dict"
Loading