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
25 changes: 18 additions & 7 deletions src/dstack/_internal/core/backends/base/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import List
from typing import List, TypeVar

from dstack._internal.core.models.common import CoreModel
from dstack._internal.core.models.runs import Job
Expand All @@ -11,14 +11,25 @@ class JobConfiguration(CoreModel):
volumes: List[Volume]


def fill_data(values: dict, filename_field: str = "filename", data_field: str = "data") -> dict:
if values.get(data_field) is not None:
return values
if (filename := values.get(filename_field)) is None:
M = TypeVar("M", bound=CoreModel)


def fill_data(model: M, filename_field: str = "filename", data_field: str = "data") -> M:
"""
Reads `data_field` from the file at `filename_field` unless it is already set.

Call from an `after` validator: the fields must be validated already, or a malformed
`filename_field` reaches `open()` as-is and raises `TypeError` instead of a validation error.
"""
if getattr(model, data_field) is not None:
return model
filename = getattr(model, filename_field)
# An unset path field defaults to `""` in some configs and to `None` in others.
if not filename:
raise ValueError(f"Either `{filename_field}` or `{data_field}` must be specified")
try:
with open(Path(filename).expanduser()) as f:
values[data_field] = f.read()
setattr(model, data_field, f.read())
except OSError:
raise ValueError(f"No such file {filename}")
return values
return model
8 changes: 4 additions & 4 deletions src/dstack/_internal/core/backends/gcp/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Annotated, Dict, List, Literal, Optional, Union

from pydantic import Field, RootModel, model_validator
from typing_extensions import Self

from dstack._internal.core.backends.base.models import fill_data
from dstack._internal.core.models.common import CoreModel
Expand Down Expand Up @@ -121,10 +122,9 @@ class GCPServiceAccountFileCreds(CoreModel):
),
] = None

@model_validator(mode="before")
@classmethod
def fill_data(cls, values):
return fill_data(values)
@model_validator(mode="after")
def fill_data(self) -> Self:
return fill_data(self)


AnyGCPFileCreds = Union[GCPServiceAccountFileCreds, GCPDefaultCreds]
Expand Down
10 changes: 4 additions & 6 deletions src/dstack/_internal/core/backends/kubernetes/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Annotated, Literal, Optional, Union

from pydantic import Field, model_validator
from typing_extensions import Self

from dstack._internal.core.backends.base.models import fill_data
from dstack._internal.core.models.common import CoreModel
Expand Down Expand Up @@ -89,12 +90,9 @@ class KubeconfigFileConfig(CoreModel):
),
] = None

@model_validator(mode="before")
@classmethod
def fill_data(cls, values: dict) -> dict:
if values.get("filename") == "" and values.get("data") is None:
raise ValueError("filename or data must be specified")
return fill_data(values)
@model_validator(mode="after")
def fill_data(self) -> Self:
return fill_data(self)


class KubernetesBackendFileConfigWithCreds(KubernetesBackendConfig):
Expand Down
20 changes: 9 additions & 11 deletions src/dstack/_internal/core/backends/nebius/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Annotated, Dict, Literal, Optional, Union

from pydantic import Field, field_serializer, model_validator
from typing_extensions import Self

from dstack._internal.core.backends.base.models import fill_data
from dstack._internal.core.models.common import CoreModel
Expand Down Expand Up @@ -76,10 +77,9 @@ class NebiusServiceAccountFileCreds(CoreModel):
Optional[str], Field(description="The path to the service account credentials file")
] = None

@model_validator(mode="before")
@classmethod
def fill_data(cls, values):
if filename := values.get("filename"):
@model_validator(mode="after")
def fill_data(self) -> Self:
if filename := self.filename:
try:
with open(Path(filename).expanduser()) as f:
data = json.load(f)
Expand All @@ -89,18 +89,16 @@ def fill_data(cls, values):

credentials = ServiceAccountCredentials.from_json(data)
subject = credentials.subject_credentials
values["service_account_id"] = subject.sub
values["public_key_id"] = subject.kid
values["private_key_content"] = subject.private_key
self.service_account_id = subject.sub
self.public_key_id = subject.kid
self.private_key_content = subject.private_key
except OSError:
raise ValueError(f"No such file {filename}")
except Exception as e:
raise ValueError(f"Failed to parse credentials file {filename}: {e}")
return values
return self

return fill_data(
values, filename_field="private_key_file", data_field="private_key_content"
)
return fill_data(self, filename_field="private_key_file", data_field="private_key_content")


AnyNebiusCreds = NebiusServiceAccountCreds
Expand Down
8 changes: 4 additions & 4 deletions src/dstack/_internal/core/backends/slurm/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Annotated, Literal, Optional, Union

from pydantic import Field, model_validator
from typing_extensions import Self

from dstack._internal.core.backends.base.models import fill_data
from dstack._internal.core.models.common import CoreModel
Expand Down Expand Up @@ -104,10 +105,9 @@ class SlurmPrivateKeyFileConfig(CoreModel):
),
] = None

@model_validator(mode="before")
@classmethod
def fill_data(cls, values: dict) -> dict:
return fill_data(values, filename_field="path", data_field="content")
@model_validator(mode="after")
def fill_data(self) -> Self:
return fill_data(self, filename_field="path", data_field="content")


class SlurmClusterFileConfig(BaseSlurmClusterConfigWithCreds):
Expand Down
19 changes: 6 additions & 13 deletions src/dstack/_internal/core/models/fleets.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,26 +384,19 @@ class FleetSpec(CoreModel):
Read profile parameters from `merged_profile` instead of `profile` directly.
"""

@model_validator(mode="before")
@classmethod
def _merged_profile(cls, values) -> Dict:
try:
# Copy first: `model_validate` returns the *same* instance for a same-class input, so
# the `setattr` loop below would otherwise mutate the caller's profile in place.
merged_profile = Profile.model_validate(values["profile"]).model_copy(deep=True)
conf = FleetConfiguration.model_validate(values["configuration"])
except KeyError:
raise ValueError("Missing profile or configuration")
@model_validator(mode="after")
def _merged_profile(self) -> Self:
merged_profile = self.profile.model_copy(deep=True)
for key in ProfileParams.model_fields:
conf_val = getattr(conf, key, None)
conf_val = getattr(self.configuration, key, None)
if conf_val is not None:
setattr(merged_profile, key, conf_val)
if merged_profile.spot_policy is None:
merged_profile.spot_policy = SpotPolicy.ONDEMAND
if merged_profile.retry is None:
merged_profile.retry = False
values["merged_profile"] = merged_profile
return values
self.merged_profile = merged_profile
return self


class Fleet(CoreModel):
Expand Down
22 changes: 7 additions & 15 deletions src/dstack/_internal/core/models/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
HTTPHeaderSpec,
HTTPMethod,
RepoExistsAction,
RunConfiguration,
ServiceConfiguration,
)
from dstack._internal.core.models.files import FileArchiveMapping
Expand Down Expand Up @@ -588,27 +587,20 @@ class RunSpec(CoreModel):
Read profile parameters from `merged_profile` instead of `profile` directly.
"""

@model_validator(mode="before")
@classmethod
def _merged_profile(cls, values) -> Dict:
if values.get("profile") is None:
@model_validator(mode="after")
def _merged_profile(self) -> Self:
if self.profile is None:
merged_profile = Profile(name="default")
else:
# Copy first: `model_validate` returns the *same* instance for a same-class input, so
# the `setattr` loop below would otherwise mutate the caller's profile in place.
merged_profile = Profile.model_validate(values["profile"]).model_copy(deep=True)
try:
conf = RunConfiguration.model_validate(values["configuration"]).root
except KeyError:
raise ValueError("Missing configuration")
merged_profile = self.profile.model_copy(deep=True)
for key in ProfileParams.model_fields:
conf_val = getattr(conf, key, None)
conf_val = getattr(self.configuration, key, None)
if conf_val is not None:
setattr(merged_profile, key, conf_val)
if merged_profile.creation_policy is None:
merged_profile.creation_policy = CreationPolicy.REUSE_OR_CREATE
values["merged_profile"] = merged_profile
return values
self.merged_profile = merged_profile
return self

@model_validator(mode="after")
def _validate_dynamo_no_retry(self) -> Self:
Expand Down
83 changes: 83 additions & 0 deletions src/tests/_internal/core/backends/base/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from pathlib import Path
from typing import Any

import pytest
from pydantic import ValidationError

from dstack._internal.core.backends.gcp.models import GCPServiceAccountFileCreds
from dstack._internal.core.backends.kubernetes.models import KubeconfigFileConfig
from dstack._internal.core.backends.slurm.models import SlurmPrivateKeyFileConfig


class TestFillData:
def test_reads_data_from_file(self, tmp_path: Path):
creds_file = tmp_path / "creds.json"
creds_file.write_text("file-contents")

creds = GCPServiceAccountFileCreds.model_validate({"filename": str(creds_file)})

assert creds.data == "file-contents"

def test_keeps_data_and_does_not_read_the_file(self, tmp_path: Path):
creds = GCPServiceAccountFileCreds.model_validate(
{"filename": str(tmp_path / "missing"), "data": "explicit-contents"}
)

assert creds.data == "explicit-contents"

def test_missing_file_rejected(self, tmp_path: Path):
with pytest.raises(ValidationError, match="No such file"):
GCPServiceAccountFileCreds.model_validate({"filename": str(tmp_path / "missing")})

@pytest.mark.parametrize(
"filename",
[
pytest.param({"a": 1}, id="dict"),
pytest.param(["/tmp/x"], id="list"),
pytest.param(True, id="bool"),
],
)
def test_malformed_filename_reported_as_validation_error(self, filename: Any):
# A `before` validator would hand these straight to `open()`, raising `TypeError`.
with pytest.raises(ValidationError):
GCPServiceAccountFileCreds.model_validate({"filename": filename})

@pytest.mark.parametrize(
"obj",
[
pytest.param("just-a-string", id="str"),
pytest.param(["filename", "/tmp/x"], id="list"),
pytest.param(12, id="int"),
],
)
def test_non_dict_input_reported_as_validation_error(self, obj: Any):
# A `before` validator would call `.get()` on these, raising `AttributeError`.
with pytest.raises(ValidationError):
GCPServiceAccountFileCreds.model_validate(obj)

@pytest.mark.parametrize(
["model", "message"],
[
pytest.param(
GCPServiceAccountFileCreds, "Either `filename` or `data`", id="gcp-empty-filename"
),
pytest.param(
KubeconfigFileConfig, "Either `filename` or `data`", id="kubernetes-empty-filename"
),
],
)
def test_empty_filename_without_data_rejected(self, model: type, message: str):
with pytest.raises(ValidationError, match=message):
model.model_validate({"filename": ""})

def test_custom_field_names(self, tmp_path: Path):
key_file = tmp_path / "key"
key_file.write_text("private-key")

config = SlurmPrivateKeyFileConfig.model_validate({"path": str(key_file)})

assert config.content == "private-key"

def test_unset_path_without_content_rejected(self):
with pytest.raises(ValidationError, match="Either `path` or `content`"):
SlurmPrivateKeyFileConfig.model_validate({})
Loading
Loading