diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index d642a81117..dca60a9d43 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -64,7 +64,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -245,8 +245,8 @@ jobs: - name: Generate json schema run: | mkdir /tmp/json-schemas - uv run python -c "from dstack._internal.core.models.configurations import DstackConfiguration; print(DstackConfiguration.schema_json())" > /tmp/json-schemas/configuration.json - uv run python -c "from dstack._internal.core.models.profiles import ProfilesConfig; print(ProfilesConfig.schema_json())" > /tmp/json-schemas/profiles.json + uv run python -c "from dstack._internal.core.models.configurations import DstackConfiguration; import json; print(json.dumps(DstackConfiguration.model_json_schema()))" > /tmp/json-schemas/configuration.json + uv run python -c "from dstack._internal.core.models.profiles import ProfilesConfig; import json; print(json.dumps(ProfilesConfig.model_json_schema()))" > /tmp/json-schemas/profiles.json - uses: actions/upload-artifact@v4 with: name: json-schemas diff --git a/pyproject.toml b/pyproject.toml index 14f40e082e..60b233223d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,16 +27,14 @@ dependencies = [ "rich-argparse", "tqdm", "questionary>=2.0.1", - "pydantic>=1.10.10,<2.0.0", - "pydantic-duality>=1.2.4", + "pydantic>=2.12", "websocket-client", "python-multipart>=0.0.16", "filelock", "psutil", - "gpuhunt==0.1.27", + "gpuhunt==0.1.29", "argcomplete>=3.5.0", "ignore-python>=0.2.0", - "orjson", "apscheduler<4", ] @@ -115,7 +113,6 @@ include = [ "src/dstack/_internal/core/backends/runpod", "src/dstack/_internal/core/backends/slurm", "src/dstack/_internal/cli/services/configurators", - "src/dstack/_internal/cli/services/endpoints", "src/dstack/_internal/cli/commands", "src/tests/_internal/server/background/pipeline_tasks", ] @@ -140,9 +137,12 @@ env = [ "DSTACK_SSHPROXY_API_TOKEN=test-token", ] filterwarnings = [ + # Fail on any use of a pydantic v1 shim (`.dict()`, `parse_obj_as`, class-based `Config`, ...) + # so they cannot creep back after the v2 migration. + "error::pydantic.PydanticDeprecatedSince20", # testcontainers modules use deprecated decorators – nothing we can do: # https://github.com/testcontainers/testcontainers-python/issues/874 - "ignore:^The @wait_container_is_ready decorator:DeprecationWarning" + "ignore:^The @wait_container_is_ready decorator:DeprecationWarning", ] [dependency-groups] diff --git a/scripts/add_backend.py b/scripts/add_backend.py index a18e48c7f2..9987d9ce9a 100644 --- a/scripts/add_backend.py +++ b/scripts/add_backend.py @@ -1,8 +1,15 @@ import argparse from pathlib import Path +from typing import Optional import jinja2 +TEMPLATE_DIR_PATH = Path(__file__).parent.parent.joinpath( + "src/dstack/_internal/core/backends/template" +) +BACKENDS_DIR_PATH = Path(__file__).parent.parent.joinpath("src/dstack/_internal/core/backends") +TEMPLATE_FILENAMES = ["backend.py", "compute.py", "configurator.py", "models.py"] + def main(): parser = argparse.ArgumentParser( @@ -21,25 +28,34 @@ def main(): generate_backend_code(args.name) -def generate_backend_code(backend_name: str): - template_dir_path = Path(__file__).parent.parent.joinpath( - "src/dstack/_internal/core/backends/template" - ) +def generate_backend_code(backend_name: str, backends_dir_path: Optional[Path] = None) -> Path: + """ + Renders the scaffold templates for a new backend. + + Args: + backend_name: The backend name in CamelCase, e.g. `VastAI`. + backends_dir_path: Where to create the backend package. Defaults to the real backends + directory; tests pass a temporary one. + + Returns: + The path of the generated backend package. + """ env = jinja2.Environment( loader=jinja2.FileSystemLoader( - searchpath=template_dir_path, + searchpath=TEMPLATE_DIR_PATH, ), keep_trailing_newline=True, ) - backend_dir_path = Path(__file__).parent.parent.joinpath( - f"src/dstack/_internal/core/backends/{backend_name.lower()}" - ) - backend_dir_path.mkdir(exist_ok=True) - for filename in ["backend.py", "compute.py", "configurator.py", "models.py"]: + if backends_dir_path is None: + backends_dir_path = BACKENDS_DIR_PATH + backend_dir_path = backends_dir_path.joinpath(backend_name.lower()) + backend_dir_path.mkdir(parents=True, exist_ok=True) + for filename in TEMPLATE_FILENAMES: template = env.get_template(f"{filename}.jinja") with open(backend_dir_path.joinpath(filename), "w+") as f: f.write(template.render({"backend_name": backend_name})) backend_dir_path.joinpath("__init__.py").write_text("") + return backend_dir_path if __name__ == "__main__": diff --git a/scripts/docs/gen_openapi_reference.py b/scripts/docs/gen_openapi_reference.py index def7cf24b6..9998e1b7be 100644 --- a/scripts/docs/gen_openapi_reference.py +++ b/scripts/docs/gen_openapi_reference.py @@ -24,7 +24,10 @@ TAG_LIST_END = "" HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} UNTAGGED_TAG = "default" -OPENAPI_VERSION = "3.0.3" +# Must stay 3.1.x: pydantic v2 generates JSON Schema draft 2020-12, so the spec contains +# constructs 3.0 has no equivalent for (`{"type": "null"}` for an optional field, `const`). +# Declaring 3.0.3 over those produces a spec no validator accepts. swagger-ui renders 3.1. +OPENAPI_VERSION = "3.1.0" if os.environ.get(disable_env): logger.warning("OpenAPI reference generation is disabled") diff --git a/scripts/docs/gen_schema_reference.py b/scripts/docs/gen_schema_reference.py index 62f379c821..1c9e9d3712 100644 --- a/scripts/docs/gen_schema_reference.py +++ b/scripts/docs/gen_schema_reference.py @@ -13,7 +13,8 @@ import mkdocs_gen_files import yaml -from pydantic.main import BaseModel +from pydantic import BaseModel, RootModel +from pydantic_core import PydanticUndefined from typing_extensions import Annotated, Any, Dict, Literal, Type, Union, get_args, get_origin from dstack._internal.core.models.resources import Range @@ -25,20 +26,65 @@ logger.info("Generating schema reference...") -def _is_linkable_type(annotation: Any) -> bool: - """Check if a type annotation contains a BaseModel subclass (excluding Range).""" +def _unwrap_optional(annotation: Any) -> Any: + """The non-`None` member of an `Optional[...]`, or the annotation unchanged.""" + if get_origin(annotation) is Union: + args = [a for a in get_args(annotation) if a is not type(None)] + if len(args) == 1: + return args[0] + return annotation + + +def _linkable_model(annotation: Any) -> Optional[type]: + """ + The `BaseModel` subclass a field links to in the reference, if any. + + pydantic v2 strips `Annotated` off `FieldInfo.annotation`, so the shape this used to unwrap by + hand (`Annotated[Optional[SSHParams], Field(...)]`) now arrives as plain `Optional[SSHParams]`. + Recursing over the annotation covers both, and also catches a bare model field, which the old + `get_args(...)[0]` approach silently missed. + """ origin = get_origin(annotation) + # The container cases come first: `get_origin(Annotated[X, ...])` is `Annotated`, which is + # itself a class, so testing `inspect.isclass` first would stop before ever unwrapping it. + if origin in (Annotated, Union, list): + for arg in get_args(annotation): + if arg is type(None): + continue + found = _linkable_model(arg) + if found is not None: + return found + return None type_ = origin if origin is not None else annotation - if inspect.isclass(type_): - return issubclass(type_, BaseModel) and not issubclass(type_, Range) - if origin is Annotated: - return _is_linkable_type(get_args(annotation)[0]) - if origin is Union: - return any(_is_linkable_type(arg) for arg in get_args(annotation)) - if origin is list: - args = get_args(annotation) - return bool(args) and _is_linkable_type(args[0]) - return False + if inspect.isclass(type_) and issubclass(type_, BaseModel) and not issubclass(type_, Range): + return type_ + return None + + +# Scalar JSON Schema types, mapped to how the docs spell them. Deliberately an allowlist rather +# than a full mapping: `array` and `object` would only restate what the annotation already renders +# more precisely (`list[str]` gaining a bare `list`, `dict` gaining `object`), and merging anything +# into a bracketed type corrupts it, since `_enrich_type_from_schema` splits the rendered type on +# `" | "` — which `list["no-capacity" | "interruption"]` contains. +_ENRICHABLE = {"string": "str", "integer": "int", "boolean": "bool", "number": "float"} + + +def _shorthand_primitives(model: Type) -> list: + """ + The primitive types a model accepts in place of its object form, e.g. `8` or `arm:8` for + `CPUSpec`. Taken from the model's own validation JSON Schema, which is the same declaration + that produces the published `configuration.json`. + """ + try: + schema = model.model_json_schema(mode="validation") + except Exception: + return [] + found = { + _ENRICHABLE[entry["type"]] + for entry in schema.get("anyOf", []) + if entry.get("type") in _ENRICHABLE + } + return sorted(found, key=_type_sort_key) def _type_sort_key(t: str) -> tuple: @@ -57,7 +103,7 @@ def _type_sort_key(t: str) -> tuple: return (5, t) -def get_friendly_type(annotation: Type) -> str: +def get_friendly_type(annotation: Any) -> str: """Get a user-friendly type string for documentation. Produces types like: ``int | str``, ``"vscode" | "cursor"``, ``list[object]``. @@ -112,10 +158,11 @@ def get_friendly_type(annotation: Type) -> str: # Range — depends on inner type parameter if issubclass(annotation, Range): - min_field = annotation.__fields__.get("min") - if min_field and inspect.isclass(min_field.type_): + min_field = annotation.model_fields.get("min") + inner = _unwrap_optional(min_field.annotation) if min_field else None + if inspect.isclass(inner): # Range[Memory] → str, Range[int] → int | str - if issubclass(min_field.type_, float): + if issubclass(inner, float): return "str" return "int | str" @@ -127,13 +174,16 @@ def get_friendly_type(annotation: Type) -> str: # BaseModel subclass (not Range) if issubclass(annotation, BaseModel) and not issubclass(annotation, Range): - # Root models (with __root__ field) — resolve from the root type - if "__root__" in annotation.__fields__: - return get_friendly_type(annotation.__fields__["__root__"].annotation) - # Models with custom __get_validators__ accept primitive input (int, str) - # in addition to the full object form (e.g., GPUSpec, CPUSpec, DiskSpec) - if "__get_validators__" in annotation.__dict__: - return "int | str | object" + # Root models — resolve from the root type + if issubclass(annotation, RootModel): + return get_friendly_type(annotation.model_fields["root"].annotation) + # Models that define their own core schema also accept a shorthand. Read which + # primitives from the model's own JSON Schema rather than assuming `int | str`: + # `CPUSpec` takes both, but `FilePathMapping` and `RepoSpec` take only a string. + if "__get_pydantic_core_schema__" in annotation.__dict__: + shorthand = _shorthand_primitives(annotation) + if shorthand: + return " | ".join([*shorthand, "object"]) return "object" # ComputeCapability (tuple subclass that parses "7.5" strings) @@ -163,33 +213,24 @@ def get_friendly_type(annotation: Type) -> str: return str(annotation) -_JSON_SCHEMA_TYPE_MAP = { - "string": "str", - "integer": "int", - "number": "float", - "boolean": "bool", - "array": "list", - "object": "object", -} - - def _enrich_type_from_schema(friendly_type: str, prop_schema: Dict[str, Any]) -> str: """Enrich the friendly type with extra accepted types from the JSON schema. - Models may define ``schema_extra`` that adds ``anyOf`` entries for fields - that accept alternative input types (e.g., duration fields typed as ``int`` - but also accepting ``str`` like ``"5m"``). + A field's annotation is its *post-validation* type, so it does not show what a before-validator + also accepts — a duration typed ``int`` takes ``"5m"``, ``false`` and ``"off"`` as well. Those + come from the type's ``json_schema_input_type``, i.e. the same declaration that produces the + published schema. """ any_of = prop_schema.get("anyOf") if not any_of: return friendly_type - # Only consider string/integer — the most common alternative input types. - # Skip boolean (typically a backward-compat artifact) and object/array. - _ENRICHABLE = {"string": "str", "integer": "int"} schema_types = set() for entry in any_of: - # Skip entries with enum constraints — those are already captured as literal values - if "enum" in entry: + # A single accepted value (`Literal["off"]`) is more useful spelled out than as `str`. + # Duplicates are removed below, so an annotation that already shows it is unaffected. + literals = [entry["const"]] if "const" in entry else entry.get("enum", []) + if literals: + schema_types.update(f'"{v}"' for v in literals if isinstance(v, str)) continue mapped = _ENRICHABLE.get(entry.get("type", "")) if mapped: @@ -200,9 +241,6 @@ def _enrich_type_from_schema(friendly_type: str, prop_schema: Dict[str, Any]) -> if not new_parts: return friendly_type all_parts = list(set(current_parts) | new_parts) - # If str is now present, single-value literals are redundant - if "str" in all_parts: - all_parts = [p for p in all_parts if not p.startswith('"') or p in all_parts] all_parts.sort(key=_type_sort_key) return " | ".join(all_parts) @@ -228,15 +266,17 @@ def generate_schema_reference( "", ] ) - # Get JSON schema to detect extra accepted types from schema_extra + # The schema says what a field *accepts*, which is wider than its annotation wherever a + # before-validator coerces. `mode="validation"` is pydantic's default, but state it: the + # serialization schema carries the narrow type and would defeat the whole point. try: - schema_props = cls.schema().get("properties", {}) + schema_props = cls.model_json_schema(mode="validation").get("properties", {}) except Exception: schema_props = {} - for name, field in cls.__fields__.items(): + for name, field in cls.model_fields.items(): default = field.default default_repr: Optional[str] - if default is None: + if default is None or default is PydanticUndefined: default_repr = None elif isinstance(default, (list, tuple, dict)) and len(default) == 0: default_repr = None @@ -252,24 +292,17 @@ def generate_schema_reference( friendly_type = _enrich_type_from_schema(friendly_type, schema_props.get(name, {})) values = dict( name=name, - description=field.field_info.description, + description=field.description, type=friendly_type, default=default_repr, - required=field.required, + required=field.is_required(), ) # TODO: If the field doesn't have description (e.g. BaseConfiguration.type), we could fallback to docstring if values["description"]: if overrides and name in overrides: values.update(overrides[name]) - field_type = next(iter(get_args(field.annotation)), None) - # TODO: This is a dirty workaround - if field_type: - if field.annotation.__name__ == "Annotated": - if field_type.__name__ in ["Optional", "List", "list", "Union"]: - field_type = get_args(field_type)[0] - base_model = _is_linkable_type(field_type) - else: - base_model = False + field_type = _linkable_model(field.annotation) + base_model = field_type is not None _defaults = ( f"Defaults to `{values['default']}`." if not base_model and values.get("default") diff --git a/src/dstack/__init__.py b/src/dstack/__init__.py index a90955ea78..e69de29bb2 100644 --- a/src/dstack/__init__.py +++ b/src/dstack/__init__.py @@ -1,4 +0,0 @@ -import sys - -if sys.version_info >= (3, 14): - raise ImportError("dstack does not support Python 3.14 or later. Please use Python 3.10–3.13.") diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index c0b4c0e715..8e723cb41b 100644 --- a/src/dstack/_internal/cli/commands/fleet.py +++ b/src/dstack/_internal/cli/commands/fleet.py @@ -15,7 +15,6 @@ from dstack._internal.cli.utils.fleet import get_fleets_table, print_fleets_table from dstack._internal.core.errors import CLIError, ResourceNotExistsError from dstack._internal.core.models.common import EntityReference -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent class FleetCommand(APIBaseCommand): @@ -176,4 +175,4 @@ def _get(self, args: argparse.Namespace): console.print(f"Fleet [code]{args.name or args.id}[/] not found") exit(1) - print(pydantic_orjson_dumps_with_indent(fleet.dict(), default=None)) + print(fleet.model_dump_json(indent=2)) diff --git a/src/dstack/_internal/cli/commands/gateway.py b/src/dstack/_internal/cli/commands/gateway.py index 71661f3f00..2489e726b4 100644 --- a/src/dstack/_internal/cli/commands/gateway.py +++ b/src/dstack/_internal/cli/commands/gateway.py @@ -20,7 +20,6 @@ from dstack._internal.core.errors import CLIError from dstack._internal.core.models.common import EntityReference from dstack._internal.core.models.gateways import GatewayStatus -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -206,4 +205,4 @@ def _get(self, args: argparse.Namespace): gateway_project=args.name.project or self.api.project, gateway_name=args.name.name, ) - print(pydantic_orjson_dumps_with_indent(gateway.dict(), default=None)) + print(gateway.model_dump_json(indent=2)) diff --git a/src/dstack/_internal/cli/commands/offer.py b/src/dstack/_internal/cli/commands/offer.py index 56c7cd2b56..5d697c5393 100644 --- a/src/dstack/_internal/cli/commands/offer.py +++ b/src/dstack/_internal/cli/commands/offer.py @@ -252,4 +252,4 @@ def _print_offers_json(run_plan: RunPlan): offers=job_plan.offers, total_offers=job_plan.total_offers, ) - print(output.json()) + print(output.model_dump_json()) diff --git a/src/dstack/_internal/cli/commands/preset.py b/src/dstack/_internal/cli/commands/preset.py index 184df2592b..ee0e2690bd 100644 --- a/src/dstack/_internal/cli/commands/preset.py +++ b/src/dstack/_internal/cli/commands/preset.py @@ -230,7 +230,7 @@ def _list(self, args: argparse.Namespace) -> None: if args.json: self._reconcile() presets = _filter_presets(PresetStore().list(), base=base, repo=repo) - print(PresetListOutput(presets=presets).json()) + print(PresetListOutput(presets=presets).model_dump_json()) return verbose = args.verbose if not getattr(args, "watch", False): @@ -330,7 +330,7 @@ def _get(self, args: argparse.Namespace) -> None: preset = PresetStore().find_by_id_or_name(args.preset) if preset is None: raise CLIError(f"Preset {args.preset!r} does not exist") - print(preset.json()) + print(preset.model_dump_json()) def _apply(self, args: argparse.Namespace) -> None: self._reconcile() @@ -493,8 +493,8 @@ def _get_effective_configuration( if getattr(args, "max_trials", None) is not None: configuration.max_trials = args.max_trials profile = load_profile_from_args(args=args, repo_dir=Path.cwd()) - for field in ProfileParams.__fields__: + for field in ProfileParams.model_fields: if getattr(configuration, field) is None: setattr(configuration, field, getattr(profile, field)) apply_profile_args(args, configuration) - return PresetConfiguration.parse_obj(configuration.dict()) + return PresetConfiguration.model_validate(configuration.model_dump()) diff --git a/src/dstack/_internal/cli/commands/run.py b/src/dstack/_internal/cli/commands/run.py index 337b0a75cf..909d2dbb27 100644 --- a/src/dstack/_internal/cli/commands/run.py +++ b/src/dstack/_internal/cli/commands/run.py @@ -5,7 +5,6 @@ from dstack._internal.cli.services.completion import RunNameCompleter from dstack._internal.cli.utils.common import console from dstack._internal.core.errors import CLIError, ResourceNotExistsError -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent class RunCommand(APIBaseCommand): @@ -66,4 +65,4 @@ def _get(self, args: argparse.Namespace): console.print(f"Run [code]{args.name or args.id}[/] not found") exit(1) - print(pydantic_orjson_dumps_with_indent(run.dict(), default=None)) + print(run.model_dump_json(indent=2)) diff --git a/src/dstack/_internal/cli/commands/volume.py b/src/dstack/_internal/cli/commands/volume.py index e78ec352c6..71c51559cc 100644 --- a/src/dstack/_internal/cli/commands/volume.py +++ b/src/dstack/_internal/cli/commands/volume.py @@ -13,7 +13,6 @@ ) from dstack._internal.cli.utils.volume import get_volumes_table, print_volumes_table from dstack._internal.core.errors import ResourceNotExistsError -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent class VolumeCommand(APIBaseCommand): @@ -114,4 +113,4 @@ def _get(self, args: argparse.Namespace): console.print("Volume not found") exit(1) - print(pydantic_orjson_dumps_with_indent(volume.dict(), default=None)) + print(volume.model_dump_json(indent=2)) diff --git a/src/dstack/_internal/cli/models/configurations.py b/src/dstack/_internal/cli/models/configurations.py index b0e08942b7..f7a3554246 100644 --- a/src/dstack/_internal/cli/models/configurations.py +++ b/src/dstack/_internal/cli/models/configurations.py @@ -1,15 +1,18 @@ from typing import Annotated, Any, Literal, Optional, Union -from pydantic import Field, PositiveInt, root_validator, validator +from pydantic import ( + Field, + PositiveInt, + field_validator, + model_validator, +) from dstack._internal.core.models.common import ( CoreModel, EntityReference, - generate_dual_core_model, ) from dstack._internal.core.models.envs import Env -from dstack._internal.core.models.profiles import ProfileParams, ProfileParamsConfig -from dstack._internal.utils.json_schema import add_extra_schema_types +from dstack._internal.core.models.profiles import ProfileParams DEFAULT_CONCURRENCY = 8 @@ -32,11 +35,13 @@ def exact_repo(self) -> str: def allows_variant_selection(self) -> bool: return False - @validator("repo") + @field_validator("repo") + @classmethod def validate_repo(cls, value: str) -> str: return _validate_model(value, field="repo") - @validator("name") + @field_validator("name") + @classmethod def validate_name(cls, value: Optional[str]) -> Optional[str]: if value is None: return None @@ -61,7 +66,8 @@ def exact_repo(self) -> None: def allows_variant_selection(self) -> bool: return True - @validator("base") + @field_validator("base") + @classmethod def validate_base(cls, value: str) -> str: return _validate_model(value, field="base") @@ -77,26 +83,16 @@ class PresetPromptFile(CoreModel): Field(description="The path to a prompt file, relative to the configuration file"), ] - @validator("path") + @field_validator("path") + @classmethod def validate_path(cls, value: str) -> str: if not value.strip(): raise ValueError("Prompt path must be a non-empty string") return value -class PresetConfigurationConfig(ProfileParamsConfig): - @staticmethod - def schema_extra(schema: dict[str, Any]): - ProfileParamsConfig.schema_extra(schema) - add_extra_schema_types( - schema["properties"]["model"], - extra_types=[{"type": "string"}], - ) - - class PresetConfiguration( ProfileParams, - generate_dual_core_model(PresetConfigurationConfig), ): type: Annotated[Literal["preset"], Field(description="The configuration type")] = "preset" name: Annotated[ @@ -158,11 +154,12 @@ class PresetConfiguration( gateway: Annotated[ Optional[Union[bool, EntityReference, str]], Field( + union_mode="left_to_right", # preserving pydantic v1 parsing behavior description=( "The name of the gateway. Specify boolean `false` to run without a gateway." " Specify boolean `true` to run with the default gateway." " Omit to run with the default gateway if there is one, or without a gateway otherwise" - ) + ), ), ] = None env: Annotated[Env, Field(description="The mapping or the list of environment variables")] = ( @@ -173,7 +170,8 @@ class PresetConfiguration( def effective_concurrency(self) -> int: return self.concurrency if self.concurrency is not None else DEFAULT_CONCURRENCY - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def apply_model_shorthand(cls, values: Any) -> Any: if not isinstance(values, dict): return values @@ -189,13 +187,15 @@ def apply_model_shorthand(cls, values: Any) -> Any: values["model"] = {"base": base} if base else {"repo": repo} return values - @validator("model", pre=True) + @field_validator("model", mode="before", json_schema_input_type=Union[PresetModelSpec, str]) + @classmethod def parse_model(cls, value: Any) -> Any: if isinstance(value, str): return {"repo": _validate_model(value, field="model")} return value - @validator("prompt") + @field_validator("prompt") + @classmethod def validate_prompt(cls, value: Any) -> Any: if isinstance(value, str): if not value.strip(): @@ -214,7 +214,7 @@ class PresetConstraints(CoreModel): context_length: Optional[PositiveInt] = None max_trials: PositiveInt concurrency: PositiveInt - fleets: list[str] = Field(min_items=1) + fleets: list[str] = Field(min_length=1) env: list[str] = [] diff --git a/src/dstack/_internal/cli/models/gateways.py b/src/dstack/_internal/cli/models/gateways.py index 94dfa88982..1428757b98 100644 --- a/src/dstack/_internal/cli/models/gateways.py +++ b/src/dstack/_internal/cli/models/gateways.py @@ -1,15 +1,10 @@ from typing import List -from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.gateways import Gateway -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent -class GatewayCommandOutputConfig(CoreConfig): - json_dumps = pydantic_orjson_dumps_with_indent - - -class GatewayCommandOutput(generate_dual_core_model(GatewayCommandOutputConfig)): +class GatewayCommandOutput(CoreModel): """JSON output model for `dstack gateway` command.""" project: str diff --git a/src/dstack/_internal/cli/models/offers.py b/src/dstack/_internal/cli/models/offers.py index 56d5e21ea7..96df7a0ea6 100644 --- a/src/dstack/_internal/cli/models/offers.py +++ b/src/dstack/_internal/cli/models/offers.py @@ -1,17 +1,12 @@ from typing import List, Literal, Optional -from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.gpus import GpuGroup from dstack._internal.core.models.instances import InstanceOfferWithAvailability from dstack._internal.core.models.resources import ResourcesSpec -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent -class OfferRequirementsConfig(CoreConfig): - json_dumps = pydantic_orjson_dumps_with_indent - - -class OfferRequirements(generate_dual_core_model(OfferRequirementsConfig)): +class OfferRequirements(CoreModel): """Profile/requirements output model for CLI commands.""" resources: ResourcesSpec @@ -20,11 +15,7 @@ class OfferRequirements(generate_dual_core_model(OfferRequirementsConfig)): reservation: Optional[str] = None -class OfferCommandOutputConfig(CoreConfig): - json_dumps = pydantic_orjson_dumps_with_indent - - -class OfferCommandOutput(generate_dual_core_model(OfferCommandOutputConfig)): +class OfferCommandOutput(CoreModel): """JSON output model for `dstack offer` command.""" project: str @@ -34,11 +25,7 @@ class OfferCommandOutput(generate_dual_core_model(OfferCommandOutputConfig)): total_offers: int -class OfferCommandGroupByGpuOutputConfig(CoreConfig): - json_dumps = pydantic_orjson_dumps_with_indent - - -class OfferCommandGroupByGpuOutput(generate_dual_core_model(OfferCommandGroupByGpuOutputConfig)): +class OfferCommandGroupByGpuOutput(CoreModel): """JSON output model for `dstack offer` command with GPU grouping.""" project: str diff --git a/src/dstack/_internal/cli/models/preset_agent.py b/src/dstack/_internal/cli/models/preset_agent.py index e756941249..8c1710d76c 100644 --- a/src/dstack/_internal/cli/models/preset_agent.py +++ b/src/dstack/_internal/cli/models/preset_agent.py @@ -1,7 +1,8 @@ import uuid from typing import Any, Dict, Optional -from pydantic import PositiveInt, root_validator +from pydantic import PositiveInt, model_validator +from typing_extensions import Self from dstack._internal.cli.models.presets import PresetBenchmark from dstack._internal.core.models.common import CoreModel @@ -100,9 +101,9 @@ class AgentFinalReport(CoreModel): benchmark: Optional[PresetBenchmark] = None failure_summary: Optional[str] = None - @root_validator - def validate_report(cls, values: dict) -> dict: - if values.get("success"): + @model_validator(mode="after") + def validate_report(self) -> Self: + if self.success: required = ( "run_id", "run_name", @@ -112,12 +113,12 @@ def validate_report(cls, values: dict) -> dict: "context_length", "benchmark", ) - missing = [field for field in required if values.get(field) in (None, "")] + missing = [field for field in required if getattr(self, field) in (None, "")] if missing: raise ValueError("successful agent report must include " + ", ".join(missing)) - elif not values.get("failure_summary"): + elif not self.failure_summary: raise ValueError("failed agent report must include failure_summary") - return values + return self class PresetAgentInfo(CoreModel): diff --git a/src/dstack/_internal/cli/models/presets.py b/src/dstack/_internal/cli/models/presets.py index 16866ecc38..500bcf42b3 100644 --- a/src/dstack/_internal/cli/models/presets.py +++ b/src/dstack/_internal/cli/models/presets.py @@ -6,10 +6,10 @@ Field, PositiveFloat, PositiveInt, - parse_obj_as, - root_validator, - validator, + field_validator, + model_validator, ) +from typing_extensions import Self from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.configurations import ServiceConfiguration @@ -58,13 +58,15 @@ class PresetBenchmark(CoreModel): target: Optional[PresetBenchmarkTarget] = None client: Optional[PresetBenchmarkClient] = None - @validator("tool", "tool_version", "command") + @field_validator("tool", "tool_version", "command") + @classmethod def validate_non_empty(cls, value: str) -> str: if not value.strip(): raise ValueError("value must be non-empty") return value - @validator("command") + @field_validator("command") + @classmethod def validate_command_has_no_bearer_token(cls, value: str) -> str: for match in re.finditer(r"(?i)\bbearer\s+([^\s\"']+)", value): token = match.group(1) @@ -77,16 +79,16 @@ def validate_command_has_no_bearer_token(cls, value: str) -> str: raise ValueError("command must not contain a bearer token value") return value - @root_validator(skip_on_failure=True) - def validate_metrics(cls, values: dict) -> dict: - metrics = values.get("metrics") - workload = values.get("workload") + @model_validator(mode="after") + def validate_metrics(self) -> Self: + metrics = self.metrics + workload = self.workload assert metrics is not None and workload is not None if metrics.failed_requests != 0: raise ValueError("benchmark must not include failed requests") if metrics.successful_requests != workload.num_requests: raise ValueError("benchmark request count must match workload.num_requests") - return values + return self class PresetValidationReplica(CoreModel): @@ -114,25 +116,26 @@ class Preset(CoreModel): service: ServiceConfiguration validations: list[PresetValidation] - @validator("base", "id", "model") + @field_validator("base", "id", "model") + @classmethod def validate_non_empty(cls, value: str) -> str: if not value.strip(): raise ValueError("value must be non-empty") return value - @root_validator - def validate_preset(cls, values: dict) -> dict: - service = values.get("service") - validations = values.get("validations") + @model_validator(mode="after") + def validate_preset(self) -> Self: + service = self.service + validations = self.validations if service is None or validations is None: - return values + return self if service.model is None: raise ValueError("preset service must specify model") if any(group.resources is None for group in service.replica_groups): raise ValueError("preset service must specify resources") if service.name is not None or service.gateway is not None: raise ValueError("preset service must not specify name or gateway") - if any(getattr(service, field) is not None for field in ProfileParams.__fields__): + if any(getattr(service, field) is not None for field in ProfileParams.model_fields): raise ValueError("preset service must not specify placement constraints") if not validations: raise ValueError("preset must include validation evidence") @@ -148,7 +151,7 @@ def validate_preset(cls, values: dict) -> dict: raise ValueError("preset validation replicas must specify resources") for resources in replica_group.resources: _validate_exact_resources(resources) - return values + return self class PresetListOutput(CoreModel): @@ -156,7 +159,7 @@ class PresetListOutput(CoreModel): def _validate_exact_resources(resources: ResourcesSpec) -> None: - cpu = parse_obj_as(CPUSpec, resources.cpu) + cpu = CPUSpec.model_validate(resources.cpu) if not _is_exact(cpu.count) or not _is_exact(resources.memory): raise ValueError("preset validation resources must be exact") if resources.disk is None or not _is_exact(resources.disk.size): diff --git a/src/dstack/_internal/cli/models/runs.py b/src/dstack/_internal/cli/models/runs.py index db951b752d..f467bc7a36 100644 --- a/src/dstack/_internal/cli/models/runs.py +++ b/src/dstack/_internal/cli/models/runs.py @@ -1,15 +1,10 @@ from typing import List -from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.runs import Run -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent -class PsCommandOutputConfig(CoreConfig): - json_dumps = pydantic_orjson_dumps_with_indent - - -class PsCommandOutput(generate_dual_core_model(PsCommandOutputConfig)): +class PsCommandOutput(CoreModel): """JSON output model for `dstack ps` command.""" project: str diff --git a/src/dstack/_internal/cli/services/args.py b/src/dstack/_internal/cli/services/args.py index a189984500..aa6f9e5f2b 100644 --- a/src/dstack/_internal/cli/services/args.py +++ b/src/dstack/_internal/cli/services/args.py @@ -1,7 +1,5 @@ from typing import Dict -from pydantic import parse_obj_as - from dstack._internal.core.models import resources as resources from dstack._internal.core.models.configurations import PortMapping from dstack._internal.core.models.envs import EnvVarTuple @@ -24,8 +22,8 @@ def cpu_spec(v: str) -> dict: def memory_spec(v: str) -> resources.Range[resources.Memory]: - return parse_obj_as(resources.Range[resources.Memory], v) + return resources.Range[resources.Memory].model_validate(v) def disk_spec(v: str) -> resources.DiskSpec: - return parse_obj_as(resources.DiskSpec, v) + return resources.DiskSpec.model_validate(v) diff --git a/src/dstack/_internal/cli/services/configurators/run.py b/src/dstack/_internal/cli/services/configurators/run.py index 41e01fb9db..df2d0b35d4 100644 --- a/src/dstack/_internal/cli/services/configurators/run.py +++ b/src/dstack/_internal/cli/services/configurators/run.py @@ -10,7 +10,6 @@ from typing import Dict, List, Optional, Set, TypeVar import gpuhunt -from pydantic import parse_obj_as from dstack._internal.cli.services.args import port_mapping from dstack._internal.cli.services.configurators.base import ( @@ -535,7 +534,7 @@ def validate_cpu_arch_and_image(self, conf: RunConfigurationT) -> None: Infers `resources.cpu.arch` if not set, requires `image` if the architecture is ARM. """ # TODO: Remove in 0.20. Use conf.resources.cpu directly - cpu_spec = parse_obj_as(CPUSpec, conf.resources.cpu) + cpu_spec = CPUSpec.model_validate(conf.resources.cpu) arch = cpu_spec.arch if arch is None: gpu_spec = conf.resources.gpu diff --git a/src/dstack/_internal/cli/services/presets/apply.py b/src/dstack/_internal/cli/services/presets/apply.py index 40495a3574..fe6eb837a8 100644 --- a/src/dstack/_internal/cli/services/presets/apply.py +++ b/src/dstack/_internal/cli/services/presets/apply.py @@ -75,11 +75,11 @@ def _build_service( configuration: PresetConfiguration, preset: Preset, ) -> ServiceConfiguration: - service = preset.service.copy(deep=True) + service = preset.service.model_copy(deep=True) service.name = configuration.name service.gateway = configuration.gateway service.env.update(configuration.env) - for field in ProfileParams.__fields__: + for field in ProfileParams.model_fields: value = getattr(configuration, field) if value is not None: setattr(service, field, value) diff --git a/src/dstack/_internal/cli/services/presets/create.py b/src/dstack/_internal/cli/services/presets/create.py index cf14cfebf6..6c84c0c3cb 100644 --- a/src/dstack/_internal/cli/services/presets/create.py +++ b/src/dstack/_internal/cli/services/presets/create.py @@ -168,7 +168,7 @@ def _load_session_configuration(agent_session: PresetAgentSession) -> PresetConf # The session copy is canonical output, not user input: parse it without # the user-facing deprecation warnings. try: - return PresetConfiguration.parse_obj( + return PresetConfiguration.model_validate( yaml.safe_load(configuration_path.read_text(encoding="utf-8")) ) except (OSError, ValueError) as e: @@ -346,7 +346,7 @@ def _resolve_preset_env( """Resolves `EnvSentinel` entries from the process environment. Non-strict drops unresolvable entries instead of raising — for attach, where env values only feed redaction and the agent already runs.""" - configuration = configuration.copy(deep=True) + configuration = configuration.model_copy(deep=True) resolved: dict[str, str] = {} for key, value in configuration.env.items(): if isinstance(value, EnvSentinel): @@ -357,7 +357,7 @@ def _resolve_preset_env( raise ConfigurationError(str(e)) from e else: resolved[key] = value - configuration.env = Env.parse_obj(resolved) + configuration.env = Env.model_validate(resolved) return configuration @@ -845,10 +845,10 @@ def _build_constraints( build_name: str, allowed_fleets: Sequence[str], ) -> str: - constraints = PresetConstraints.parse_obj( + constraints = PresetConstraints.model_validate( { "run_name_prefix": build_name, - "model": json.loads(configuration.model.json(exclude_none=True)), + "model": json.loads(configuration.model.model_dump_json(exclude_none=True)), "context_length": configuration.context_length, "max_trials": configuration.max_trials, "concurrency": configuration.effective_concurrency, @@ -857,7 +857,7 @@ def _build_constraints( } ) # All fields are always present; unset optional constraints render as null. - return json.dumps(json.loads(constraints.json()), indent=2) + "\n" + return json.dumps(json.loads(constraints.model_dump_json()), indent=2) + "\n" def _save_final_report_copy( diff --git a/src/dstack/_internal/cli/services/presets/output.py b/src/dstack/_internal/cli/services/presets/output.py index 4a9da5252b..35da0994fc 100644 --- a/src/dstack/_internal/cli/services/presets/output.py +++ b/src/dstack/_internal/cli/services/presets/output.py @@ -108,7 +108,9 @@ def _add_session(table: Table, session: dict[str, Any]) -> None: created_at = session.get("created_at") if isinstance(created_at, str): try: - created = pretty_date(datetime.fromisoformat(created_at)) + # `Z` is what pydantic v2 emits for UTC, and `datetime.fromisoformat` only + # accepts it from Python 3.11; dstack supports 3.10. + created = pretty_date(datetime.fromisoformat(created_at.replace("Z", "+00:00"))) except ValueError: created = created_at benchmark = "" diff --git a/src/dstack/_internal/cli/services/presets/presets.py b/src/dstack/_internal/cli/services/presets/presets.py index 3b4db39ee6..983c8ba821 100644 --- a/src/dstack/_internal/cli/services/presets/presets.py +++ b/src/dstack/_internal/cli/services/presets/presets.py @@ -29,10 +29,10 @@ def build_preset( preset_id: Optional[str] = None, name: Optional[str] = None, ) -> Preset: - service = service.copy(deep=True) + service = service.model_copy(deep=True) service.name = None service.gateway = None - for field in ProfileParams.__fields__: + for field in ProfileParams.model_fields: setattr(service, field, None) validation = PresetValidation( replicas=validation_replicas, @@ -76,7 +76,8 @@ def preset_to_data(preset: Preset) -> dict[str, Any]: "created_at": preset.created_at.isoformat(), "service": service_configuration_to_preset_data(preset.service), "validations": [ - json.loads(validation.json(exclude_none=True)) for validation in preset.validations + json.loads(validation.model_dump_json(exclude_none=True)) + for validation in preset.validations ], } @@ -84,11 +85,11 @@ def preset_to_data(preset: Preset) -> dict[str, Any]: def service_configuration_to_preset_data( configuration: ServiceConfiguration, ) -> dict[str, Any]: - service_data = json.loads(configuration.json(exclude_none=True)) + service_data = json.loads(configuration.model_dump_json(exclude_none=True)) service_data.pop("type", None) service_data.pop("name", None) service_data.pop("gateway", None) - for field in ProfileParams.__fields__: + for field in ProfileParams.model_fields: service_data.pop(field, None) if configuration.env: service_data["env"] = [ @@ -129,7 +130,7 @@ def resources_spec_from_instance_resources(resources: Resources) -> ResourcesSpe data["gpu"]["vendor"] = first_gpu.vendor.value else: data["gpu"] = 0 - return ResourcesSpec.parse_obj(data) + return ResourcesSpec.model_validate(data) def set_service_gpu_vendors_from_validations( diff --git a/src/dstack/_internal/cli/services/presets/session.py b/src/dstack/_internal/cli/services/presets/session.py index 72ec86e0fe..1f32bea6da 100644 --- a/src/dstack/_internal/cli/services/presets/session.py +++ b/src/dstack/_internal/cli/services/presets/session.py @@ -93,7 +93,7 @@ def write_agent_info(self, auth: "ClaudeAuth") -> None: _get_claude_version, ) - info = ClaudeAgentInfo.parse_obj( + info = ClaudeAgentInfo.model_validate( { "executable": auth.executable, "version": _get_claude_version(auth), @@ -105,7 +105,8 @@ def write_agent_info(self, auth: "ClaudeAuth") -> None: } ) _write_private_text( - self.path / "agent.json", json.dumps(json.loads(info.json()), indent=2) + "\n" + self.path / "agent.json", + json.dumps(json.loads(info.model_dump_json()), indent=2) + "\n", ) def append_log(self, line: str) -> None: @@ -177,7 +178,7 @@ def create_preset_agent_session( "debug": debug, } _write_private_text(path / _SESSION_FILENAME, json.dumps(manifest, indent=2) + "\n") - data = json.loads(configuration.json(exclude_none=True)) + data = json.loads(configuration.model_dump_json(exclude_none=True)) if configuration.env: data["env"] = list(configuration.env) else: diff --git a/src/dstack/_internal/cli/services/presets/store.py b/src/dstack/_internal/cli/services/presets/store.py index 6f944d71c2..b48b350e4a 100644 --- a/src/dstack/_internal/cli/services/presets/store.py +++ b/src/dstack/_internal/cli/services/presets/store.py @@ -96,7 +96,7 @@ def release_name(self, name: str) -> Preset | None: preset = self.find_by_name(name) if preset is None: return None - detached = preset.copy(update={"name": None}) + detached = preset.model_copy(update={"name": None}) self.save(detached) return detached @@ -138,7 +138,7 @@ def _migrate_legacy(self) -> None: def _load(self, path: Path) -> Preset: try: with path.open(encoding="utf-8") as f: - return Preset.parse_obj(yaml.safe_load(f)) + return Preset.model_validate(yaml.safe_load(f)) except (OSError, ValidationError, yaml.YAMLError) as e: raise CLIError(f"Invalid preset file {path}: {e}") from e @@ -167,7 +167,7 @@ def _parse_preset_configuration(stream: TextIO) -> PresetConfiguration: data = yaml.safe_load(stream) if not isinstance(data, dict): raise ConfigurationError("Preset configuration must be a YAML object") - configuration = PresetConfiguration.parse_obj(data) + configuration = PresetConfiguration.model_validate(data) except ValidationError as e: raise ConfigurationError(e) from e except yaml.YAMLError as e: diff --git a/src/dstack/_internal/cli/services/presets/verify.py b/src/dstack/_internal/cli/services/presets/verify.py index 983b397a4c..2dd4c8005c 100644 --- a/src/dstack/_internal/cli/services/presets/verify.py +++ b/src/dstack/_internal/cli/services/presets/verify.py @@ -52,7 +52,7 @@ def load_preset_agent_report( # check below still rejects unknown leaked tokens. report_data = redact_structure(report_data, redacted_values) try: - report = AgentFinalReport.parse_obj(report_data) + report = AgentFinalReport.model_validate(report_data) except ValidationError as e: raise CLIError(f"Claude returned an invalid final report: {e}") from e if not report.success: @@ -100,13 +100,13 @@ def build_verified_preset( target_type = ( "gateway" if urlparse(run.service.url).scheme in {"http", "https"} else "server-proxy" ) - benchmark = report.benchmark.copy( + benchmark = report.benchmark.model_copy( update={ "target": PresetBenchmarkTarget(type=target_type), "client": PresetBenchmarkClient(type="local"), } ) - portable_service = service.copy(deep=True) + portable_service = service.model_copy(deep=True) # The CLI resolved preset env references before submission; presets retain the references. for key, value in preset_configuration.env.items(): if isinstance(value, EnvSentinel) and key in portable_service.env: diff --git a/src/dstack/_internal/cli/services/profile.py b/src/dstack/_internal/cli/services/profile.py index 0ef420d77e..3daae28225 100644 --- a/src/dstack/_internal/cli/services/profile.py +++ b/src/dstack/_internal/cli/services/profile.py @@ -2,14 +2,18 @@ import os from dstack._internal.core.errors import CLIError +from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.duration import ( + parse_duration, + parse_idle_duration, + parse_off_duration, +) from dstack._internal.core.models.profiles import ( CreationPolicy, Profile, ProfileParams, ProfileRetry, SpotPolicy, - parse_duration, - parse_max_duration, ) from dstack._internal.utils.env import environ from dstack._internal.utils.path import PathLike @@ -61,6 +65,7 @@ def register_profile_args(parser: argparse.ArgumentParser): action="append", metavar="NAME", dest="backends", + type=BackendType, help="The backends that will be tried for provisioning", ) profile_group.add_argument( @@ -104,8 +109,9 @@ def register_profile_args(parser: argparse.ArgumentParser): fleets_group_exc.add_argument( "--idle-duration", dest="idle_duration", - type=str, + type=idle_duration, help="Time to wait before destroying the idle instance (if the run provisions a new instance)", + metavar="DURATION", ) spot_group = parser.add_argument_group("Spot policy") @@ -204,8 +210,12 @@ def apply_profile_args( ) -def max_duration(v: str) -> int: - return parse_max_duration(v) +def max_duration(v: str): + return parse_off_duration(v) + + +def idle_duration(v: str): + return parse_idle_duration(v) def retry_duration(v: str) -> int: diff --git a/src/dstack/_internal/cli/services/resources.py b/src/dstack/_internal/cli/services/resources.py index e81b6078db..7d69db0b3e 100644 --- a/src/dstack/_internal/cli/services/resources.py +++ b/src/dstack/_internal/cli/services/resources.py @@ -45,9 +45,9 @@ def register_resources_args(parser: ArgsParser) -> None: def apply_resources_args(args: argparse.Namespace, conf: AnyRunConfiguration) -> None: if args.cpu_spec: - conf.resources.cpu = resources.CPUSpec.parse_obj(args.cpu_spec) + conf.resources.cpu = resources.CPUSpec.model_validate(args.cpu_spec) if args.gpu_spec: - conf.resources.gpu = resources.GPUSpec.parse_obj(args.gpu_spec) + conf.resources.gpu = resources.GPUSpec.model_validate(args.gpu_spec) if args.memory_spec: conf.resources.memory = args.memory_spec if args.disk_spec: diff --git a/src/dstack/_internal/cli/utils/gateway.py b/src/dstack/_internal/cli/utils/gateway.py index 4c80aaaa8c..704741c89c 100644 --- a/src/dstack/_internal/cli/utils/gateway.py +++ b/src/dstack/_internal/cli/utils/gateway.py @@ -53,7 +53,7 @@ def print_gateways_json(gateways: List[Gateway], project: str) -> None: project=project, gateways=gateways, ) - print(output.json()) + print(output.model_dump_json()) def get_gateways_table( diff --git a/src/dstack/_internal/cli/utils/gpu.py b/src/dstack/_internal/cli/utils/gpu.py index 3d19b173ba..7eb0fc9bb5 100644 --- a/src/dstack/_internal/cli/utils/gpu.py +++ b/src/dstack/_internal/cli/utils/gpu.py @@ -31,7 +31,7 @@ def print_gpu_json( gpus=gpus, ) - print(output.json()) + print(output.model_dump_json()) def print_gpu_table(gpus: List[GpuGroup], run_spec: RunSpec, group_by: List[str], project: str): diff --git a/src/dstack/_internal/cli/utils/run.py b/src/dstack/_internal/cli/utils/run.py index fd90b35708..6c27f2aa6f 100644 --- a/src/dstack/_internal/cli/utils/run.py +++ b/src/dstack/_internal/cli/utils/run.py @@ -59,7 +59,7 @@ def print_runs_json(project: str, runs: List[Run]) -> None: project=project, runs=[r._run for r in runs], ) - print(output.json()) + print(output.model_dump_json()) def print_run_plan( diff --git a/src/dstack/_internal/core/backends/aws/compute.py b/src/dstack/_internal/core/backends/aws/compute.py index 7fc586b4a7..2e0a65b568 100644 --- a/src/dstack/_internal/core/backends/aws/compute.py +++ b/src/dstack/_internal/core/backends/aws/compute.py @@ -55,7 +55,7 @@ ProvisioningError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_json_extra_ignore from dstack._internal.core.models.gateways import ( GatewayComputeConfiguration, GatewayLoadBalancerConfiguration, @@ -204,7 +204,7 @@ def get_offers_post_filter( def _get_offers_post_filter_cached_key(self, requirements: Requirements) -> int: # Requirements is not hashable, so we use a hack to get arguments hash - return hash(requirements.json()) + return hash(requirements.model_dump_json()) @cachedmethod( cache=lambda self: self._offers_post_filter_cache.cache, @@ -468,7 +468,7 @@ def update_provisioning_data( ) provisioning_data.backend_data = AWSInstanceBackendData( eip_allocation_id=allocation_id - ).json() + ).model_dump_json() provisioning_data.hostname = public_ip else: provisioning_data.hostname = _get_instance_ip( @@ -715,7 +715,7 @@ def create_gateway_load_balancer( tg_arn=tg_arn, listener_arn=listener_arn, http_listener_arn=http_listener_arn, - ).json(), + ).model_dump_json(), ) def terminate_gateway( @@ -742,7 +742,7 @@ def terminate_gateway_load_balancer( ) return try: - backend_data_parsed = AWSGatewayBackendData.__response__.parse_raw(backend_data) + backend_data_parsed = validate_json_extra_ignore(AWSGatewayBackendData, backend_data) except ValidationError: logger.exception( "Failed to terminate load balancer for gateway %s: backend_data parsing error.", @@ -772,8 +772,8 @@ def register_gateway_replica_with_load_balancer( " gateway_backend_data is None" ) try: - gateway_backend_data_parsed = AWSGatewayBackendData.__response__.parse_raw( - gateway_backend_data + gateway_backend_data_parsed = validate_json_extra_ignore( + AWSGatewayBackendData, gateway_backend_data ) except ValidationError as e: raise ComputeError( @@ -810,8 +810,8 @@ def deregister_gateway_replica_from_load_balancer( " gateway_backend_data is None" ) try: - gateway_backend_data_parsed = AWSGatewayBackendData.__response__.parse_raw( - gateway_backend_data + gateway_backend_data_parsed = validate_json_extra_ignore( + AWSGatewayBackendData, gateway_backend_data ) except ValidationError as e: raise ComputeError( @@ -861,7 +861,7 @@ def register_volume(self, volume: Volume) -> VolumeProvisioningData: backend_data=AWSVolumeBackendData( volume_type=response_volume["VolumeType"], iops=response_volume["Iops"], - ).json(), + ).model_dump_json(), ) def create_volume(self, volume: Volume) -> VolumeProvisioningData: @@ -920,7 +920,7 @@ def create_volume(self, volume: Volume) -> VolumeProvisioningData: backend_data=AWSVolumeBackendData( volume_type=response["VolumeType"], iops=iops, - ).json(), + ).model_dump_json(), ) def delete_volume(self, volume: Volume): @@ -1161,7 +1161,10 @@ def _get_image_id_and_username_cache_key( image_config: Optional[AWSOSImageConfig] = None, ) -> tuple: return hashkey( - region, gpu_name, instance_type, image_config.json() if image_config else None + region, + gpu_name, + instance_type, + image_config.model_dump_json() if image_config else None, ) @cachedmethod( @@ -1424,7 +1427,7 @@ def _parse_instance_backend_data(backend_data: Optional[str]) -> "AWSInstanceBac if backend_data is None: return AWSInstanceBackendData() try: - return AWSInstanceBackendData.__response__.parse_raw(backend_data) + return validate_json_extra_ignore(AWSInstanceBackendData, backend_data) except ValidationError: logger.exception("Failed to parse AWS instance backend_data; treating as empty") return AWSInstanceBackendData() diff --git a/src/dstack/_internal/core/backends/aws/configurator.py b/src/dstack/_internal/core/backends/aws/configurator.py index a7a1b92a3a..2920ac1c97 100644 --- a/src/dstack/_internal/core/backends/aws/configurator.py +++ b/src/dstack/_internal/core/backends/aws/configurator.py @@ -28,6 +28,10 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import ( + validate_extra_ignore, + validate_json_extra_ignore, +) from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -86,27 +90,30 @@ def create_backend( config.regions = DEFAULT_REGIONS return BackendRecord( config=AWSStoredConfig( - **AWSBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=AWSCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(AWSBackendConfig, config).model_dump() + ).model_dump_json(), + auth=AWSCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> AWSBackendConfigWithCreds: config = self._get_config(record) - return AWSBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(AWSBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> AWSBackendConfig: config = self._get_config(record) - return AWSBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(AWSBackendConfig, config) def get_backend(self, record: BackendRecord) -> AWSBackend: config = self._get_config(record) return AWSBackend(config=config) def _get_config(self, record: BackendRecord) -> AWSConfig: - return AWSConfig.__response__( - **json.loads(record.config), - creds=AWSCreds.__response__.parse_raw(record.auth).__root__, + return validate_extra_ignore( + AWSConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(AWSCreds, record.auth).root, + }, ) def _check_config_tags(self, config: AWSBackendConfigWithCreds): @@ -179,7 +186,8 @@ def _check_config_vpc(self, session: Session, config: AWSBackendConfigWithCreds) future = executor.submit( compute.get_vpc_id_subnets_ids_or_error, ec2_client=ec2_client, - config=AWSConfig.parse_obj(config), + # `config` is an `AWSBackendConfigWithCreds`, a different class. + config=AWSConfig.model_validate(config.model_dump()), region=region, allocate_public_ip=allocate_public_ip, ) diff --git a/src/dstack/_internal/core/backends/aws/models.py b/src/dstack/_internal/core/backends/aws/models.py index 11b9708d00..2dce3bcecc 100644 --- a/src/dstack/_internal/core/backends/aws/models.py +++ b/src/dstack/_internal/core/backends/aws/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import Field +from pydantic import Field, RootModel from dstack._internal.core.models.common import CoreModel @@ -9,7 +9,7 @@ class AWSOSImage(CoreModel): name: Annotated[str, Field(description="The AMI name")] owner: Annotated[ str, - Field(regex=r"^(\d{12}|self)$", description="The AMI owner, account ID or `self`"), + Field(pattern=r"^(\d{12}|self)$", description="The AMI owner, account ID or `self`"), ] = "self" user: Annotated[str, Field(description="The OS user for provisioning")] @@ -38,8 +38,8 @@ class AWSDefaultCreds(CoreModel): AnyAWSCreds = Union[AWSAccessKeyCreds, AWSDefaultCreds] -class AWSCreds(CoreModel): - __root__: AnyAWSCreds = Field(..., discriminator="type") +class AWSCreds(RootModel[Annotated[AnyAWSCreds, Field(discriminator="type")]]): + pass class AWSBackendConfig(CoreModel): diff --git a/src/dstack/_internal/core/backends/azure/configurator.py b/src/dstack/_internal/core/backends/azure/configurator.py index 33a8576981..bea1b3bad3 100644 --- a/src/dstack/_internal/core/backends/azure/configurator.py +++ b/src/dstack/_internal/core/backends/azure/configurator.py @@ -46,6 +46,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore LOCATIONS = [ ("(US) Central US", "centralus"), @@ -129,18 +130,18 @@ def create_backend( ) return BackendRecord( config=AzureStoredConfig( - **AzureBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=AzureCreds.parse_obj(config.creds).__root__.json(), + **validate_extra_ignore(AzureBackendConfig, config).model_dump() + ).model_dump_json(), + auth=AzureCreds.model_validate(config.creds).root.model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> AzureBackendConfigWithCreds: config = self._get_config(record) - return AzureBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(AzureBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> AzureBackendConfig: config = self._get_config(record) - return AzureBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(AzureBackendConfig, config) def get_backend(self, record: BackendRecord) -> AzureBackend: config = self._get_config(record) @@ -152,10 +153,13 @@ def _get_config(self, record: BackendRecord) -> AzureConfig: if regions is None: # Legacy config stores regions as locations regions = config_dict.pop("locations") - return AzureConfig.__response__( - **config_dict, - regions=regions, - creds=AzureCreds.__response__.parse_raw(record.auth).__root__, + return validate_extra_ignore( + AzureConfig, + { + **config_dict, + "regions": regions, + "creds": validate_json_extra_ignore(AzureCreds, record.auth).root, + }, ) def _check_config_tenant_id( diff --git a/src/dstack/_internal/core/backends/azure/models.py b/src/dstack/_internal/core/backends/azure/models.py index 0d7c11a116..dc6c174c34 100644 --- a/src/dstack/_internal/core/backends/azure/models.py +++ b/src/dstack/_internal/core/backends/azure/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import Field +from pydantic import Field, RootModel from dstack._internal.core.models.common import CoreModel @@ -20,8 +20,8 @@ class AzureDefaultCreds(CoreModel): AnyAzureCreds = Union[AzureClientCreds, AzureDefaultCreds] -class AzureCreds(CoreModel): - __root__: AnyAzureCreds = Field(..., discriminator="type") +class AzureCreds(RootModel[Annotated[AnyAzureCreds, Field(discriminator="type")]]): + pass class AzureBackendConfig(CoreModel): diff --git a/src/dstack/_internal/core/backends/base/compute.py b/src/dstack/_internal/core/backends/base/compute.py index 33013abdf8..cf0e6a919a 100644 --- a/src/dstack/_internal/core/backends/base/compute.py +++ b/src/dstack/_internal/core/backends/base/compute.py @@ -342,7 +342,7 @@ def _get_offers_cached_key( ) -> int: hash_items: list[Union[str, bool]] = [] # Requirements is not hashable, so we use a hack to get arguments hash - hash_items.append(requirements.json()) + hash_items.append(requirements.model_dump_json()) if self.full_offers_argument_has_effect: hash_items.append(full_offers) if self.unallocated_resources_argument_has_effect: @@ -405,7 +405,7 @@ def run_job( reservation=job.job_spec.requirements.reservation, tags=run.run_spec.merged_profile.tags, ) - instance_offer = instance_offer.copy() + instance_offer = instance_offer.model_copy() self._restrict_instance_offer_az_to_volumes_az(instance_offer, volumes) return self.create_instance( instance_offer, instance_config, placement_group=placement_group diff --git a/src/dstack/_internal/core/backends/base/offers.py b/src/dstack/_internal/core/backends/base/offers.py index 681b6f9557..cb3f974f87 100644 --- a/src/dstack/_internal/core/backends/base/offers.py +++ b/src/dstack/_internal/core/backends/base/offers.py @@ -6,7 +6,6 @@ import gpuhunt from cachetools import TTLCache -from pydantic import parse_obj_as from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.instances import ( @@ -106,17 +105,17 @@ def catalog_item_to_offer( ) if disk_size_mib is None: return None - resources = Resources.construct( + resources = Resources.model_construct( cpu_arch=item.cpu_arch, cpus=item.cpu, memory_mib=round(item.memory * 1024), gpus=gpus, spot=item.spot, - disk=Disk.construct(size_mib=disk_size_mib), + disk=Disk.model_construct(size_mib=disk_size_mib), ) - return InstanceOffer.construct( + return InstanceOffer.model_construct( backend=backend, - instance=InstanceType.construct( + instance=InstanceType.model_construct( name=item.instance_name, resources=resources, ), @@ -172,7 +171,7 @@ def requirements_to_query_filter(req: Optional[Requirements]) -> gpuhunt.QueryFi res = req.resources if res.cpu: # TODO: Remove in 0.20. Use res.cpu directly - cpu = parse_obj_as(CPUSpec, res.cpu) + cpu = CPUSpec.model_validate(res.cpu) q.cpu_arch = cpu.arch q.min_cpu = cpu.count.min q.max_cpu = cpu.count.max @@ -250,7 +249,7 @@ def modifier(offer: InstanceOfferWithAvailability) -> Optional[InstanceOfferWith disk_size_range = requirements_disk_range.intersect(configurable_disk_size) if disk_size_range is None: return None - offer_copy = offer.copy(deep=True) + offer_copy = offer.model_copy(deep=True) offer_copy.instance.resources.disk = Disk( size_mib=get_or_error(disk_size_range.min) * 1024 ) diff --git a/src/dstack/_internal/core/backends/cloudrift/configurator.py b/src/dstack/_internal/core/backends/cloudrift/configurator.py index 9f69958026..82dd1ebc64 100644 --- a/src/dstack/_internal/core/backends/cloudrift/configurator.py +++ b/src/dstack/_internal/core/backends/cloudrift/configurator.py @@ -18,6 +18,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class CloudRiftConfigurator( @@ -39,29 +40,32 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=CloudRiftStoredConfig( - **CloudRiftBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=CloudRiftCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(CloudRiftBackendConfig, config).model_dump() + ).model_dump_json(), + auth=CloudRiftCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds( self, record: BackendRecord ) -> CloudRiftBackendConfigWithCreds: config = self._get_config(record) - return CloudRiftBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(CloudRiftBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> CloudRiftBackendConfig: config = self._get_config(record) - return CloudRiftBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(CloudRiftBackendConfig, config) def get_backend(self, record: BackendRecord) -> CloudRiftBackend: config = self._get_config(record) return CloudRiftBackend(config=config) def _get_config(self, record: BackendRecord) -> CloudRiftConfig: - return CloudRiftConfig.__response__( - **json.loads(record.config), - creds=CloudRiftCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + CloudRiftConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(CloudRiftCreds, record.auth), + }, ) def _validate_creds(self, creds: AnyCloudRiftCreds): diff --git a/src/dstack/_internal/core/backends/crusoe/compute.py b/src/dstack/_internal/core/backends/crusoe/compute.py index 97b3b85a31..13e54eef67 100644 --- a/src/dstack/_internal/core/backends/crusoe/compute.py +++ b/src/dstack/_internal/core/backends/crusoe/compute.py @@ -24,7 +24,7 @@ from dstack._internal.core.backends.crusoe.resources import CrusoeClient from dstack._internal.core.errors import BackendError, NotYetTerminated from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_json_extra_ignore from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceConfiguration, @@ -291,7 +291,7 @@ def create_instance( ssh_port=22, username="ubuntu", dockerized=True, - backend_data=CrusoeInstanceBackendData(data_disk_id=data_disk_id).json(), + backend_data=CrusoeInstanceBackendData(data_disk_id=data_disk_id).model_dump_json(), ) def update_provisioning_data( @@ -356,7 +356,7 @@ def create_placement_group( backend=BackendType.CRUSOE, backend_data=CrusoePlacementGroupBackendData( ib_partition_id=None, ib_network_id=None - ).json(), + ).model_dump_json(), ) ib_networks = self._client.list_ib_networks() @@ -385,7 +385,7 @@ def create_placement_group( backend_data=CrusoePlacementGroupBackendData( ib_partition_id=partition["id"], ib_network_id=target_network["id"], - ).json(), + ).model_dump_json(), ) def delete_placement_group(self, placement_group: PlacementGroup) -> None: @@ -422,7 +422,7 @@ class CrusoeInstanceBackendData(CoreModel): def load(cls, raw: Optional[str]) -> "CrusoeInstanceBackendData": if raw is None: return cls() - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) class CrusoePlacementGroupBackendData(CoreModel): @@ -433,4 +433,4 @@ class CrusoePlacementGroupBackendData(CoreModel): def load(cls, raw: Optional[str]) -> "CrusoePlacementGroupBackendData": if raw is None: return cls() - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) diff --git a/src/dstack/_internal/core/backends/crusoe/configurator.py b/src/dstack/_internal/core/backends/crusoe/configurator.py index a6e4274a43..d10f4dc535 100644 --- a/src/dstack/_internal/core/backends/crusoe/configurator.py +++ b/src/dstack/_internal/core/backends/crusoe/configurator.py @@ -15,6 +15,7 @@ ) from dstack._internal.core.backends.crusoe.resources import CrusoeClient from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class CrusoeConfigurator( @@ -54,25 +55,28 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=CrusoeStoredConfig( - **CrusoeBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=CrusoeCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(CrusoeBackendConfig, config).model_dump() + ).model_dump_json(), + auth=CrusoeCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> CrusoeBackendConfigWithCreds: config = self._get_config(record) - return CrusoeBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(CrusoeBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> CrusoeBackendConfig: config = self._get_config(record) - return CrusoeBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(CrusoeBackendConfig, config) def get_backend(self, record: BackendRecord) -> CrusoeBackend: config = self._get_config(record) return CrusoeBackend(config=config) def _get_config(self, record: BackendRecord) -> CrusoeConfig: - return CrusoeConfig.__response__( - **json.loads(record.config), - creds=CrusoeCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + CrusoeConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(CrusoeCreds, record.auth), + }, ) diff --git a/src/dstack/_internal/core/backends/digitalocean_base/configurator.py b/src/dstack/_internal/core/backends/digitalocean_base/configurator.py index 9f0fc21699..a20c128c5b 100644 --- a/src/dstack/_internal/core/backends/digitalocean_base/configurator.py +++ b/src/dstack/_internal/core/backends/digitalocean_base/configurator.py @@ -14,6 +14,7 @@ BaseDigitalOceanCreds, BaseDigitalOceanStoredConfig, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class BaseDigitalOceanConfigurator(Configurator): @@ -27,30 +28,33 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=BaseDigitalOceanStoredConfig( - **BaseDigitalOceanBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=BaseDigitalOceanCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(BaseDigitalOceanBackendConfig, config).model_dump() + ).model_dump_json(), + auth=BaseDigitalOceanCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds( self, record: BackendRecord ) -> BaseDigitalOceanBackendConfigWithCreds: config = self._get_config(record) - return BaseDigitalOceanBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(BaseDigitalOceanBackendConfigWithCreds, config) def get_backend_config_without_creds( self, record: BackendRecord ) -> BaseDigitalOceanBackendConfig: config = self._get_config(record) - return BaseDigitalOceanBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(BaseDigitalOceanBackendConfig, config) def get_backend(self, record: BackendRecord) -> BaseDigitalOceanBackend: raise NotImplementedError("Subclasses must implement get_backend") def _get_config(self, record: BackendRecord) -> BaseDigitalOceanConfig: - return BaseDigitalOceanConfig.__response__( - **json.loads(record.config), - creds=BaseDigitalOceanCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + BaseDigitalOceanConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(BaseDigitalOceanCreds, record.auth), + }, ) def _validate_creds(self, creds: AnyBaseDigitalOceanCreds, project_name: Optional[str] = None): diff --git a/src/dstack/_internal/core/backends/gcp/compute.py b/src/dstack/_internal/core/backends/gcp/compute.py index 54aa640bfb..b79857754d 100644 --- a/src/dstack/_internal/core/backends/gcp/compute.py +++ b/src/dstack/_internal/core/backends/gcp/compute.py @@ -55,7 +55,7 @@ ProvisioningError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_extra_ignore from dstack._internal.core.models.gateways import ( GatewayComputeConfiguration, GatewayProvisioningData, @@ -194,7 +194,7 @@ def reservation_modifier( zones_with_capacity.append(zone) if not matching_zones: return None - offer = offer.copy(deep=True) + offer = offer.model_copy(deep=True) if zones_with_capacity: offer.availability_zones = zones_with_capacity else: @@ -214,8 +214,8 @@ def get_offers_post_filter( def reserved_offers_filter(offer: InstanceOfferWithAvailability) -> bool: """Remove reserved-only offers""" - if GCPOfferBackendData.__response__.parse_obj( - offer.backend_data + if validate_extra_ignore( + GCPOfferBackendData, offer.backend_data ).is_dws_calendar_mode: return False return True @@ -678,7 +678,7 @@ def register_volume(self, volume: Volume) -> VolumeProvisioningData: detachable=True, backend_data=GCPVolumeDiskBackendData( disk_type=gcp_resources.full_resource_name_to_name(disk.type_), - ).json(), + ).model_dump_json(), ) raise ComputeError(f"Persistent disk {volume.configuration.volume_id} not found") @@ -746,7 +746,7 @@ def create_volume(self, volume: Volume) -> VolumeProvisioningData: detachable=True, backend_data=GCPVolumeDiskBackendData( disk_type=gcp_resources.full_resource_name_to_name(disk.type_), - ).json(), + ).model_dump_json(), ) def delete_volume(self, volume: Volume): diff --git a/src/dstack/_internal/core/backends/gcp/configurator.py b/src/dstack/_internal/core/backends/gcp/configurator.py index ce15264fd1..1621756cc9 100644 --- a/src/dstack/_internal/core/backends/gcp/configurator.py +++ b/src/dstack/_internal/core/backends/gcp/configurator.py @@ -23,6 +23,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore LOCATIONS = [ { @@ -146,27 +147,30 @@ def create_backend( config.regions = DEFAULT_REGIONS return BackendRecord( config=GCPStoredConfig( - **GCPBackendConfig.__response__.parse_obj(config).dict(), - ).json(), - auth=GCPCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(GCPBackendConfig, config).model_dump(), + ).model_dump_json(), + auth=GCPCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> GCPBackendConfigWithCreds: config = self._get_config(record) - return GCPBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(GCPBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> GCPBackendConfig: config = self._get_config(record) - return GCPBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(GCPBackendConfig, config) def get_backend(self, record: BackendRecord) -> GCPBackend: config = self._get_config(record) return GCPBackend(config=config) def _get_config(self, record: BackendRecord) -> GCPConfig: - return GCPConfig.__response__( - **json.loads(record.config), - creds=GCPCreds.__response__.parse_raw(record.auth).__root__, + return validate_extra_ignore( + GCPConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(GCPCreds, record.auth).root, + }, ) def _check_config_tags(self, config: GCPBackendConfigWithCreds): diff --git a/src/dstack/_internal/core/backends/gcp/models.py b/src/dstack/_internal/core/backends/gcp/models.py index 10c5a42d0f..47809329d9 100644 --- a/src/dstack/_internal/core/backends/gcp/models.py +++ b/src/dstack/_internal/core/backends/gcp/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import Field, root_validator +from pydantic import Field, RootModel, model_validator from dstack._internal.core.backends.base.models import fill_data from dstack._internal.core.models.common import CoreModel @@ -23,8 +23,8 @@ class GCPDefaultCreds(CoreModel): AnyGCPCreds = Union[GCPServiceAccountCreds, GCPDefaultCreds] -class GCPCreds(CoreModel): - __root__: AnyGCPCreds = Field(..., discriminator="type") +class GCPCreds(RootModel[Annotated[AnyGCPCreds, Field(discriminator="type")]]): + pass class GCPBackendConfig(CoreModel): @@ -56,7 +56,7 @@ class GCPBackendConfig(CoreModel): " A VPC should have eight subnets to maximize the bandwidth in clusters" " with eight-GPU instances." ), - max_items=1, # The currently supported instance types only need one VPC with eight subnets. + max_length=1, # The currently supported instance types only need one VPC with eight subnets. ), ] = None vpc_project_id: Annotated[ @@ -96,7 +96,7 @@ class GCPBackendConfig(CoreModel): "The list of preview GCP features to enable." " There are currently no preview features" ), - max_items=1, + max_length=1, ), ] = None @@ -121,7 +121,8 @@ class GCPServiceAccountFileCreds(CoreModel): ), ] = None - @root_validator + @model_validator(mode="before") + @classmethod def fill_data(cls, values): return fill_data(values) diff --git a/src/dstack/_internal/core/backends/hotaisle/compute.py b/src/dstack/_internal/core/backends/hotaisle/compute.py index c23ab717ce..eabe705ec6 100644 --- a/src/dstack/_internal/core/backends/hotaisle/compute.py +++ b/src/dstack/_internal/core/backends/hotaisle/compute.py @@ -19,7 +19,11 @@ from dstack._internal.core.backends.hotaisle.api_client import HotAisleAPIClient from dstack._internal.core.backends.hotaisle.models import HotAisleConfig from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import ( + CoreModel, + validate_extra_ignore, + validate_json_extra_ignore, +) from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceConfiguration, @@ -74,8 +78,8 @@ def create_instance( ) -> JobProvisioningData: project_ssh_key = instance_config.ssh_keys[0] self.api_client.upload_ssh_key(project_ssh_key.public) - offer_backend_data: HotAisleOfferBackendData = ( - HotAisleOfferBackendData.__response__.parse_obj(instance_offer.backend_data) + offer_backend_data: HotAisleOfferBackendData = validate_extra_ignore( + HotAisleOfferBackendData, instance_offer.backend_data ) vm_data = self.api_client.create_virtual_machine(offer_backend_data.vm_specs) return JobProvisioningData( @@ -90,7 +94,9 @@ def create_instance( ssh_port=22, dockerized=True, ssh_proxy=None, - backend_data=HotAisleInstanceBackendData(ip_address=vm_data["ip_address"]).json(), + backend_data=HotAisleInstanceBackendData( + ip_address=vm_data["ip_address"] + ).model_dump_json(), ) def update_provisioning_data( @@ -182,7 +188,7 @@ class HotAisleInstanceBackendData(CoreModel): @classmethod def load(cls, raw: Optional[str]) -> "HotAisleInstanceBackendData": assert raw is not None - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) class HotAisleOfferBackendData(CoreModel): diff --git a/src/dstack/_internal/core/backends/hotaisle/configurator.py b/src/dstack/_internal/core/backends/hotaisle/configurator.py index 19162ca992..6d3ff7a393 100644 --- a/src/dstack/_internal/core/backends/hotaisle/configurator.py +++ b/src/dstack/_internal/core/backends/hotaisle/configurator.py @@ -17,6 +17,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class HotAisleConfigurator( @@ -36,29 +37,32 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=HotAisleStoredConfig( - **HotAisleBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=HotAisleCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(HotAisleBackendConfig, config).model_dump() + ).model_dump_json(), + auth=HotAisleCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds( self, record: BackendRecord ) -> HotAisleBackendConfigWithCreds: config = self._get_config(record) - return HotAisleBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(HotAisleBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> HotAisleBackendConfig: config = self._get_config(record) - return HotAisleBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(HotAisleBackendConfig, config) def get_backend(self, record: BackendRecord) -> HotAisleBackend: config = self._get_config(record) return HotAisleBackend(config=config) def _get_config(self, record: BackendRecord) -> HotAisleConfig: - return HotAisleConfig.__response__( - **json.loads(record.config), - creds=HotAisleCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + HotAisleConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(HotAisleCreds, record.auth), + }, ) def _validate_creds(self, creds: AnyHotAisleCreds, team_handle: str): diff --git a/src/dstack/_internal/core/backends/jarvislabs/compute.py b/src/dstack/_internal/core/backends/jarvislabs/compute.py index f6468ab89f..fcdb65a9f1 100644 --- a/src/dstack/_internal/core/backends/jarvislabs/compute.py +++ b/src/dstack/_internal/core/backends/jarvislabs/compute.py @@ -26,7 +26,7 @@ from dstack._internal.core.backends.jarvislabs.models import JarvisLabsConfig from dstack._internal.core.errors import ProvisioningError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_json_extra_ignore from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceConfiguration, @@ -62,7 +62,7 @@ class JarvisLabsInstanceBackendData(CoreModel): def load(cls, raw: Optional[str]) -> "JarvisLabsInstanceBackendData": if raw is None: return cls() - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) class JarvisLabsCompute( @@ -169,7 +169,7 @@ def create_instance( ssh_port=22, dockerized=True, ssh_proxy=None, - backend_data=JarvisLabsInstanceBackendData(ssh_key_ids=ssh_key_ids).json(), + backend_data=JarvisLabsInstanceBackendData(ssh_key_ids=ssh_key_ids).model_dump_json(), ) def update_provisioning_data( diff --git a/src/dstack/_internal/core/backends/jarvislabs/configurator.py b/src/dstack/_internal/core/backends/jarvislabs/configurator.py index 041694256f..1dd358a92c 100644 --- a/src/dstack/_internal/core/backends/jarvislabs/configurator.py +++ b/src/dstack/_internal/core/backends/jarvislabs/configurator.py @@ -18,6 +18,7 @@ ) from dstack._internal.core.errors import ServerClientError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class JarvisLabsConfigurator( @@ -40,29 +41,32 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=JarvisLabsStoredConfig( - **JarvisLabsBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=JarvisLabsCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(JarvisLabsBackendConfig, config).model_dump() + ).model_dump_json(), + auth=JarvisLabsCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds( self, record: BackendRecord ) -> JarvisLabsBackendConfigWithCreds: config = self._get_config(record) - return JarvisLabsBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(JarvisLabsBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> JarvisLabsBackendConfig: config = self._get_config(record) - return JarvisLabsBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(JarvisLabsBackendConfig, config) def get_backend(self, record: BackendRecord) -> JarvisLabsBackend: config = self._get_config(record) return JarvisLabsBackend(config=config) def _get_config(self, record: BackendRecord) -> JarvisLabsConfig: - return JarvisLabsConfig.__response__( - **json.loads(record.config), - creds=JarvisLabsCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + JarvisLabsConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(JarvisLabsCreds, record.auth), + }, ) def _validate_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/kubernetes/compute.py b/src/dstack/_internal/core/backends/kubernetes/compute.py index 3340aeee5e..3fb02ce8f3 100644 --- a/src/dstack/_internal/core/backends/kubernetes/compute.py +++ b/src/dstack/_internal/core/backends/kubernetes/compute.py @@ -77,7 +77,7 @@ from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT from dstack._internal.core.errors import ComputeError, ProvisioningError, SkipOffer from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_json_extra_ignore from dstack._internal.core.models.gateways import ( GatewayComputeConfiguration, GatewayProvisioningData, @@ -128,7 +128,7 @@ class KubernetesBackendData(CoreModel): @classmethod def load(cls, raw: str) -> Self: - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) class KubernetesCompute( @@ -349,7 +349,7 @@ def run_job( instance_type=instance_offer.instance, internal_ip=None, ssh_proxy=None, - backend_data=backend_data.json(), + backend_data=backend_data.model_dump_json(), ) def update_provisioning_data( @@ -825,7 +825,7 @@ def _get_amd_gpu_node_affinity( def _offer_modifier( resource_requests: ResourceRequests, offer: InstanceOfferWithAvailability ) -> InstanceOfferWithAvailability: - offer_copy = offer.copy(deep=True) + offer_copy = offer.model_copy(deep=True) adjust_resources_by_resource_requests(offer_copy.instance.resources, resource_requests) return offer_copy diff --git a/src/dstack/_internal/core/backends/kubernetes/configurator.py b/src/dstack/_internal/core/backends/kubernetes/configurator.py index b8872f0211..8a4e2e17f0 100644 --- a/src/dstack/_internal/core/backends/kubernetes/configurator.py +++ b/src/dstack/_internal/core/backends/kubernetes/configurator.py @@ -16,6 +16,7 @@ ) from dstack._internal.core.errors import ServerClientError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -49,7 +50,7 @@ def create_backend( self, project_name: str, config: KubernetesBackendConfigWithCreds ) -> BackendRecord: return BackendRecord( - config=KubernetesStoredConfig.__response__.parse_obj(config).json(), + config=validate_extra_ignore(KubernetesStoredConfig, config).model_dump_json(), auth="", ) @@ -57,17 +58,17 @@ def get_backend_config_with_creds( self, record: BackendRecord ) -> KubernetesBackendConfigWithCreds: config = self._get_config(record) - return KubernetesBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(KubernetesBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> KubernetesBackendConfig: config = self._get_config(record) - return KubernetesBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(KubernetesBackendConfig, config) def get_backend(self, record: BackendRecord) -> KubernetesBackend: return KubernetesBackend(self._get_config(record)) def _get_config(self, record: BackendRecord) -> KubernetesConfig: - return KubernetesConfig.__response__.parse_raw(record.config) + return validate_json_extra_ignore(KubernetesConfig, record.config) def _check_config_contexts(self, config: KubernetesBackendConfig): if config.contexts is None: diff --git a/src/dstack/_internal/core/backends/kubernetes/models.py b/src/dstack/_internal/core/backends/kubernetes/models.py index eb92982e45..c374bc8e62 100644 --- a/src/dstack/_internal/core/backends/kubernetes/models.py +++ b/src/dstack/_internal/core/backends/kubernetes/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Literal, Optional, Union -from pydantic import Field, root_validator +from pydantic import Field, model_validator from dstack._internal.core.backends.base.models import fill_data from dstack._internal.core.models.common import CoreModel @@ -89,7 +89,8 @@ class KubeconfigFileConfig(CoreModel): ), ] = None - @root_validator + @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") diff --git a/src/dstack/_internal/core/backends/kubernetes/utils.py b/src/dstack/_internal/core/backends/kubernetes/utils.py index a40e8abcbe..b6be7d0e7a 100644 --- a/src/dstack/_internal/core/backends/kubernetes/utils.py +++ b/src/dstack/_internal/core/backends/kubernetes/utils.py @@ -30,7 +30,7 @@ KubernetesBackendConfigWithCreds, KubernetesProxyJumpConfig, ) -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_extra_ignore from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -192,7 +192,7 @@ def kubeconfig_data_to_kubeconfig_dict(kubeconfig_data: str) -> dict: def kubeconfig_dict_to_kubeconfig(kubeconfig_dict: dict) -> Kubeconfig: - return Kubeconfig.__response__.parse_obj(kubeconfig_dict) + return validate_extra_ignore(Kubeconfig, kubeconfig_dict) def call_api_method( diff --git a/src/dstack/_internal/core/backends/lambdalabs/configurator.py b/src/dstack/_internal/core/backends/lambdalabs/configurator.py index 7e4d49c717..bcb8ad4f3f 100644 --- a/src/dstack/_internal/core/backends/lambdalabs/configurator.py +++ b/src/dstack/_internal/core/backends/lambdalabs/configurator.py @@ -17,6 +17,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class LambdaConfigurator( @@ -36,27 +37,30 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=LambdaStoredConfig( - **LambdaBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=LambdaCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(LambdaBackendConfig, config).model_dump() + ).model_dump_json(), + auth=LambdaCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> LambdaBackendConfigWithCreds: config = self._get_config(record) - return LambdaBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(LambdaBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> LambdaBackendConfig: config = self._get_config(record) - return LambdaBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(LambdaBackendConfig, config) def get_backend(self, record: BackendRecord) -> LambdaBackend: config = self._get_config(record) return LambdaBackend(config=config) def _get_config(self, record: BackendRecord) -> LambdaConfig: - return LambdaConfig.__response__( - **json.loads(record.config), - creds=LambdaCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + LambdaConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(LambdaCreds, record.auth), + }, ) def _validate_lambda_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/models.py b/src/dstack/_internal/core/backends/models.py index bf06dbf3f0..25824a1eea 100644 --- a/src/dstack/_internal/core/backends/models.py +++ b/src/dstack/_internal/core/backends/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Union -from pydantic import Field +from pydantic import Field, RootModel from dstack._internal.core.backends.aws.models import ( AWSBackendConfig, @@ -142,43 +142,23 @@ DstackBackendConfig, ] -# Permissive counterpart of `AnyBackendConfigWithCreds` for parsing server responses. -# A newer server may add config fields that an older client's models don't know about; -# parsing with the strict variant would reject the response outright. +# The same union tagged for validation. Without the discriminator, arm selection would depend on +# trying each of the 20 arms in order and reporting 20 errors when none match. # -# Discriminated on `type`: without it, arm selection would depend on trying each of the 20 -# arms in order, which only works because every arm happens to declare a `Literal` type. -# `AnyBackendConfigWithCreds` above stays a bare `Union` on purpose. Its two server-side users apply -# `Field(discriminator="type")` at the point of use, which is fine against a bare alias. -# Baking the discriminator into the alias would turn those into doubled `Annotated` `Field`s and -# fail with `ValueError: cannot specify multiple 'Annotated' 'Field's`. -# Discriminating here is because nothing else wraps this alias. -AnyBackendConfigWithCredsResponse = Annotated[ - Union[ - AWSBackendConfigWithCreds.__response__, - AzureBackendConfigWithCreds.__response__, - CloudRiftBackendConfigWithCreds.__response__, - CrusoeBackendConfigWithCreds.__response__, - CudoBackendConfigWithCreds.__response__, - VerdaBackendConfigWithCreds.__response__, - BaseDigitalOceanBackendConfigWithCreds.__response__, - GCPBackendConfigWithCreds.__response__, - HotAisleBackendConfigWithCreds.__response__, - JarvisLabsBackendConfigWithCreds.__response__, - KubernetesBackendConfigWithCreds.__response__, - LambdaBackendConfigWithCreds.__response__, - OCIBackendConfigWithCreds.__response__, - NebiusBackendConfigWithCreds.__response__, - RunpodBackendConfigWithCreds.__response__, - TensorDockBackendConfigWithCreds.__response__, - VastAIBackendConfigWithCreds.__response__, - VultrBackendConfigWithCreds.__response__, - SlurmBackendConfigWithCreds.__response__, - DstackBackendConfig.__response__, - ], +# `AnyBackendConfigWithCreds` above stays a bare `Union` because it is also used as a plain type +# annotation and as the bound of `BackendConfigWithCredsT` in `base/configurator.py`. Every site +# that *validates* the union should use this alias instead of wrapping it again locally: two +# `Annotated` `Field`s on the same type fail with "cannot specify multiple 'Annotated' 'Field's". +AnyBackendConfigWithCredsTagged = Annotated[ + AnyBackendConfigWithCreds, Field(discriminator="type"), ] + +class BackendConfigWithCreds(RootModel[AnyBackendConfigWithCredsTagged]): + pass + + # Backend config accepted in server/config.yaml. # This can be different from the API config. # For example, it can make creds data optional and resolve it by filename. diff --git a/src/dstack/_internal/core/backends/nebius/compute.py b/src/dstack/_internal/core/backends/nebius/compute.py index a12eb7a205..620884945b 100644 --- a/src/dstack/_internal/core/backends/nebius/compute.py +++ b/src/dstack/_internal/core/backends/nebius/compute.py @@ -40,7 +40,11 @@ ProvisioningError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import ( + CoreModel, + validate_extra_ignore, + validate_json_extra_ignore, +) from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceConfiguration, @@ -240,7 +244,9 @@ def create_instance( ssh_port=22, username="ubuntu", dockerized=True, - backend_data=NebiusInstanceBackendData(boot_disk_id=create_disk_op.resource_id).json(), + backend_data=NebiusInstanceBackendData( + boot_disk_id=create_disk_op.resource_id + ).model_dump_json(), ) def update_provisioning_data( @@ -285,8 +291,8 @@ def create_placement_group( master_instance_offer: InstanceOffer, ) -> PlacementGroupProvisioningData: assert placement_group.configuration.placement_strategy == PlacementStrategy.CLUSTER - master_instance_offer_backend_data: NebiusOfferBackendData = ( - NebiusOfferBackendData.__response__.parse_obj(master_instance_offer.backend_data) + master_instance_offer_backend_data: NebiusOfferBackendData = validate_extra_ignore( + NebiusOfferBackendData, master_instance_offer.backend_data ) fabrics = list(master_instance_offer_backend_data.fabrics) if self.config.fabrics is not None: @@ -308,7 +314,7 @@ def create_placement_group( ) return PlacementGroupProvisioningData( backend=BackendType.NEBIUS, - backend_data=placement_group_backend_data.json(), + backend_data=placement_group_backend_data.model_dump_json(), ) def delete_placement_group(self, placement_group: PlacementGroup) -> None: @@ -331,8 +337,8 @@ def is_suitable_placement_group( placement_group_backend_data = NebiusPlacementGroupBackendData.load( placement_group.provisioning_data.backend_data ) - instance_offer_backend_data: NebiusOfferBackendData = ( - NebiusOfferBackendData.__response__.parse_obj(instance_offer.backend_data) + instance_offer_backend_data: NebiusOfferBackendData = validate_extra_ignore( + NebiusOfferBackendData, instance_offer.backend_data ) return ( placement_group_backend_data.cluster is None @@ -346,7 +352,7 @@ class NebiusInstanceBackendData(CoreModel): @classmethod def load(cls, raw: Optional[str]) -> "NebiusInstanceBackendData": assert raw is not None - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) class NebiusClusterBackendData(CoreModel): @@ -360,7 +366,7 @@ class NebiusPlacementGroupBackendData(CoreModel): @classmethod def load(cls, raw: Optional[str]) -> "NebiusPlacementGroupBackendData": assert raw is not None - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) def _wait_for_instance(sdk: SDK, op: SDKOperation[Operation]) -> None: diff --git a/src/dstack/_internal/core/backends/nebius/configurator.py b/src/dstack/_internal/core/backends/nebius/configurator.py index a349863527..483d4c4137 100644 --- a/src/dstack/_internal/core/backends/nebius/configurator.py +++ b/src/dstack/_internal/core/backends/nebius/configurator.py @@ -21,6 +21,7 @@ from dstack._internal.core.backends.nebius.resources import get_all_infiniband_fabrics from dstack._internal.core.errors import BackendError, ServerClientError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class NebiusConfigurator( @@ -74,25 +75,28 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=NebiusStoredConfig( - **NebiusBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=NebiusCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(NebiusBackendConfig, config).model_dump() + ).model_dump_json(), + auth=NebiusCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> NebiusBackendConfigWithCreds: config = self._get_config(record) - return NebiusBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(NebiusBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> NebiusBackendConfig: config = self._get_config(record) - return NebiusBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(NebiusBackendConfig, config) def get_backend(self, record: BackendRecord) -> NebiusBackend: config = self._get_config(record) return NebiusBackend(config=config) def _get_config(self, record: BackendRecord) -> NebiusConfig: - return NebiusConfig.__response__( - **json.loads(record.config), - creds=NebiusCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + NebiusConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(NebiusCreds, record.auth), + }, ) diff --git a/src/dstack/_internal/core/backends/nebius/models.py b/src/dstack/_internal/core/backends/nebius/models.py index 143eb55746..9adbc12a05 100644 --- a/src/dstack/_internal/core/backends/nebius/models.py +++ b/src/dstack/_internal/core/backends/nebius/models.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Annotated, Dict, Literal, Optional, Union -from pydantic import Field, root_validator +from pydantic import Field, field_serializer, model_validator from dstack._internal.core.backends.base.models import fill_data from dstack._internal.core.models.common import CoreModel @@ -76,7 +76,8 @@ class NebiusServiceAccountFileCreds(CoreModel): Optional[str], Field(description="The path to the service account credentials file") ] = None - @root_validator + @model_validator(mode="before") + @classmethod def fill_data(cls, values): if filename := values.get("filename"): try: @@ -183,3 +184,7 @@ class NebiusConfig(NebiusStoredConfig): class NebiusOfferBackendData(CoreModel): fabrics: set[str] = set() + + @field_serializer("fabrics") + def _serialize_fabrics(self, value: set[str]) -> list[str]: + return sorted(value) diff --git a/src/dstack/_internal/core/backends/nebius/resources.py b/src/dstack/_internal/core/backends/nebius/resources.py index c9871f2da8..9450f5b45d 100644 --- a/src/dstack/_internal/core/backends/nebius/resources.py +++ b/src/dstack/_internal/core/backends/nebius/resources.py @@ -57,6 +57,7 @@ ) from dstack._internal.core.errors import BackendError, NoCapacityError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.utils.event_loop import DaemonEventLoop from dstack._internal.utils.logging import get_logger @@ -257,8 +258,8 @@ def get_all_infiniband_fabrics() -> set[str]: offers = get_catalog_offers(backend=BackendType.NEBIUS) result = set() for offer in offers: - backend_data: NebiusOfferBackendData = NebiusOfferBackendData.__response__.parse_obj( - offer.backend_data + backend_data: NebiusOfferBackendData = validate_extra_ignore( + NebiusOfferBackendData, offer.backend_data ) result |= backend_data.fabrics return result diff --git a/src/dstack/_internal/core/backends/oci/auth.py b/src/dstack/_internal/core/backends/oci/auth.py index c751c10a35..9681ef8eec 100644 --- a/src/dstack/_internal/core/backends/oci/auth.py +++ b/src/dstack/_internal/core/backends/oci/auth.py @@ -8,7 +8,7 @@ def get_client_config(creds: AnyOCICreds) -> Mapping[str, Any]: if isinstance(creds, OCIDefaultCreds): return oci.config.from_file(file_location=creds.file, profile_name=creds.profile) - return creds.dict(exclude={"type"}) + return creds.model_dump(exclude={"type"}) def creds_valid(creds: AnyOCICreds) -> bool: diff --git a/src/dstack/_internal/core/backends/oci/configurator.py b/src/dstack/_internal/core/backends/oci/configurator.py index 61ee596ad6..95696330e7 100644 --- a/src/dstack/_internal/core/backends/oci/configurator.py +++ b/src/dstack/_internal/core/backends/oci/configurator.py @@ -26,6 +26,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore # where dstack images are published SUPPORTED_REGIONS = frozenset( @@ -78,31 +79,38 @@ def create_backend( project_name, config, subscribed_regions.home_region_name ) config.compartment_id = compartment_id - stored_config = OCIStoredConfig.__response__( - **config.dict(), subnet_ids_per_region=subnet_ids_per_region + stored_config = validate_extra_ignore( + OCIStoredConfig, + { + **config.model_dump(), + "subnet_ids_per_region": subnet_ids_per_region, + }, ) return BackendRecord( - config=stored_config.json(), - auth=OCICreds.parse_obj(config.creds).json(), + config=stored_config.model_dump_json(), + auth=OCICreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> OCIBackendConfigWithCreds: config = self._get_config(record) - return OCIBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(OCIBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> OCIBackendConfig: config = self._get_config(record) - return OCIBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(OCIBackendConfig, config) def get_backend(self, record: BackendRecord) -> OCIBackend: config = self._get_config(record) return OCIBackend(config=config) def _get_config(self, record: BackendRecord) -> OCIConfig: - return OCIConfig.__response__( - **json.loads(record.config), - creds=OCICreds.__response__.parse_raw(record.auth).__root__, + return validate_extra_ignore( + OCIConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(OCICreds, record.auth).root, + }, ) diff --git a/src/dstack/_internal/core/backends/oci/models.py b/src/dstack/_internal/core/backends/oci/models.py index 4212efecb3..20f22b793d 100644 --- a/src/dstack/_internal/core/backends/oci/models.py +++ b/src/dstack/_internal/core/backends/oci/models.py @@ -1,6 +1,7 @@ from typing import Annotated, Dict, List, Literal, Optional, Union -from pydantic import Field, root_validator +from pydantic import Field, RootModel, model_validator +from typing_extensions import Self from dstack._internal.core.models.common import CoreModel @@ -29,14 +30,14 @@ class OCIClientCreds(CoreModel): str, Field(description="Name or key of any region the tenancy is subscribed to") ] - @root_validator - def key_file_xor_key_content(cls, values): - key_file, key_content = values["key_file"], values["key_content"] + @model_validator(mode="after") + def key_file_xor_key_content(self) -> Self: + key_file, key_content = self.key_file, self.key_content if key_file and key_content: raise ValueError("key_file and key_content are mutually exclusive") if not key_file and not key_content: raise ValueError("Either key_file or key_content should be set") - return values + return self class OCIDefaultCreds(CoreModel): @@ -50,8 +51,8 @@ class OCIDefaultCreds(CoreModel): AnyOCICreds = Union[OCIClientCreds, OCIDefaultCreds] -class OCICreds(CoreModel): - __root__: AnyOCICreds = Field(..., discriminator="type") +class OCICreds(RootModel[Annotated[AnyOCICreds, Field(discriminator="type")]]): + pass class OCIBackendConfig(CoreModel): diff --git a/src/dstack/_internal/core/backends/runpod/compute.py b/src/dstack/_internal/core/backends/runpod/compute.py index b918139e1b..e0ccc2a41c 100644 --- a/src/dstack/_internal/core/backends/runpod/compute.py +++ b/src/dstack/_internal/core/backends/runpod/compute.py @@ -28,7 +28,7 @@ ComputeError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel, RegistryAuth +from dstack._internal.core.models.common import CoreModel, RegistryAuth, validate_extra_ignore from dstack._internal.core.models.compute_groups import ComputeGroup, ComputeGroupProvisioningData from dstack._internal.core.models.instances import ( InstanceAvailability, @@ -485,8 +485,8 @@ def _is_secure_cloud(region: str) -> bool: def _get_offer_pod_counts(offer: InstanceOfferWithAvailability) -> list[int]: - backend_data: RunpodOfferBackendData = RunpodOfferBackendData.__response__.parse_obj( - offer.backend_data + backend_data: RunpodOfferBackendData = validate_extra_ignore( + RunpodOfferBackendData, offer.backend_data ) pod_counts = backend_data.pod_counts or [] return pod_counts diff --git a/src/dstack/_internal/core/backends/runpod/configurator.py b/src/dstack/_internal/core/backends/runpod/configurator.py index c2c3221318..eb4dfd7dba 100644 --- a/src/dstack/_internal/core/backends/runpod/configurator.py +++ b/src/dstack/_internal/core/backends/runpod/configurator.py @@ -15,6 +15,7 @@ RunpodStoredConfig, ) from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class RunpodConfigurator( @@ -34,27 +35,30 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=RunpodStoredConfig( - **RunpodBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=RunpodCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(RunpodBackendConfig, config).model_dump() + ).model_dump_json(), + auth=RunpodCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> RunpodBackendConfigWithCreds: config = self._get_config(record) - return RunpodBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(RunpodBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> RunpodBackendConfig: config = self._get_config(record) - return RunpodBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(RunpodBackendConfig, config) def get_backend(self, record: BackendRecord) -> RunpodBackend: config = self._get_config(record) return RunpodBackend(config=config) def _get_config(self, record: BackendRecord) -> RunpodConfig: - return RunpodConfig.__response__( - **json.loads(record.config), - creds=RunpodCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + RunpodConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(RunpodCreds, record.auth), + }, ) def _validate_runpod_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/slurm/compute.py b/src/dstack/_internal/core/backends/slurm/compute.py index ca9088b8a7..3ae14d5d99 100644 --- a/src/dstack/_internal/core/backends/slurm/compute.py +++ b/src/dstack/_internal/core/backends/slurm/compute.py @@ -361,7 +361,7 @@ def _offer_modifier( if not filtered_partitions: return None - offer_copy = offer.copy(deep=True) + offer_copy = offer.model_copy(deep=True) _adjust_resources(offer_copy.instance.resources, requested_resources) offer_copy.availability_zones = list(filtered_partitions) return offer_copy diff --git a/src/dstack/_internal/core/backends/slurm/configurator.py b/src/dstack/_internal/core/backends/slurm/configurator.py index 084dcf5552..99bd7f957b 100644 --- a/src/dstack/_internal/core/backends/slurm/configurator.py +++ b/src/dstack/_internal/core/backends/slurm/configurator.py @@ -14,6 +14,7 @@ ) from dstack._internal.core.errors import ServerClientError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class SlurmConfigurator( @@ -36,23 +37,23 @@ def create_backend( self, project_name: str, config: SlurmBackendConfigWithCreds ) -> BackendRecord: return BackendRecord( - config=SlurmStoredConfig.__response__.parse_obj(config).json(), + config=validate_extra_ignore(SlurmStoredConfig, config).model_dump_json(), auth="", ) def get_backend_config_with_creds(self, record: BackendRecord) -> SlurmBackendConfigWithCreds: config = self._get_config(record) - return SlurmBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(SlurmBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> SlurmBackendConfig: config = self._get_config(record) - return SlurmBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(SlurmBackendConfig, config) def get_backend(self, record: BackendRecord) -> SlurmBackend: return SlurmBackend(self._get_config(record)) def _get_config(self, record: BackendRecord) -> SlurmConfig: - return SlurmConfig.__response__.parse_raw(record.config) + return validate_json_extra_ignore(SlurmConfig, record.config) def _check_clusters(self, clusters: list[SlurmCluster]) -> None: error_messages: list[str] = [] diff --git a/src/dstack/_internal/core/backends/slurm/models.py b/src/dstack/_internal/core/backends/slurm/models.py index 485fd96bcd..f34e6b7e92 100644 --- a/src/dstack/_internal/core/backends/slurm/models.py +++ b/src/dstack/_internal/core/backends/slurm/models.py @@ -1,6 +1,6 @@ from typing import Annotated, Literal, Optional, Union -from pydantic import Field, root_validator +from pydantic import Field, model_validator from dstack._internal.core.backends.base.models import fill_data from dstack._internal.core.models.common import CoreModel @@ -104,7 +104,8 @@ class SlurmPrivateKeyFileConfig(CoreModel): ), ] = None - @root_validator + @model_validator(mode="before") + @classmethod def fill_data(cls, values: dict) -> dict: return fill_data(values, filename_field="path", data_field="content") diff --git a/src/dstack/_internal/core/backends/template/configurator.py.jinja b/src/dstack/_internal/core/backends/template/configurator.py.jinja index 47ea303903..8206004261 100644 --- a/src/dstack/_internal/core/backends/template/configurator.py.jinja +++ b/src/dstack/_internal/core/backends/template/configurator.py.jinja @@ -17,6 +17,7 @@ from dstack._internal.core.backends.{{ backend_name|lower }}.models import ( from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class {{ backend_name }}Configurator( @@ -39,27 +40,30 @@ class {{ backend_name }}Configurator( ) -> BackendRecord: return BackendRecord( config={{ backend_name }}StoredConfig( - **{{ backend_name }}BackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth={{ backend_name }}Creds.parse_obj(config.creds).json(), + **validate_extra_ignore({{ backend_name }}BackendConfig, config).model_dump() + ).model_dump_json(), + auth={{ backend_name }}Creds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> {{ backend_name }}BackendConfigWithCreds: config = self._get_config(record) - return {{ backend_name }}BackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore({{ backend_name }}BackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> {{ backend_name }}BackendConfig: config = self._get_config(record) - return {{ backend_name }}BackendConfig.__response__.parse_obj(config) + return validate_extra_ignore({{ backend_name }}BackendConfig, config) def get_backend(self, record: BackendRecord) -> {{ backend_name }}Backend: config = self._get_config(record) return {{ backend_name }}Backend(config=config) def _get_config(self, record: BackendRecord) -> {{ backend_name }}Config: - return {{ backend_name }}Config.__response__( - **json.loads(record.config), - creds={{ backend_name }}Creds.parse_raw(record.auth), + return validate_extra_ignore( + {{ backend_name }}Config, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore({{ backend_name }}Creds, record.auth), + }, ) def _validate_creds(self, creds: Any{{ backend_name }}Creds): diff --git a/src/dstack/_internal/core/backends/vastai/compute.py b/src/dstack/_internal/core/backends/vastai/compute.py index bf85fc315a..a419b84bec 100644 --- a/src/dstack/_internal/core/backends/vastai/compute.py +++ b/src/dstack/_internal/core/backends/vastai/compute.py @@ -28,7 +28,7 @@ from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT from dstack._internal.core.errors import ComputeError, NoCapacityError, ProvisioningError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_extra_ignore from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceOfferWithAvailability, @@ -131,8 +131,8 @@ def run_job( commands = get_docker_commands( [run.run_spec.ssh_key_pub.strip(), project_ssh_public_key.strip()] ) - offer_backend_data: VastAIOfferBackendData = VastAIOfferBackendData.__response__.parse_obj( - instance_offer.backend_data + offer_backend_data: VastAIOfferBackendData = validate_extra_ignore( + VastAIOfferBackendData, instance_offer.backend_data ) bid = None if instance_offer.instance.resources.spot: diff --git a/src/dstack/_internal/core/backends/vastai/configurator.py b/src/dstack/_internal/core/backends/vastai/configurator.py index cab9dbb10a..6dde3c0894 100644 --- a/src/dstack/_internal/core/backends/vastai/configurator.py +++ b/src/dstack/_internal/core/backends/vastai/configurator.py @@ -17,6 +17,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore REGIONS = [] @@ -40,27 +41,30 @@ def create_backend( config.regions = REGIONS return BackendRecord( config=VastAIStoredConfig( - **VastAIBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=VastAICreds.parse_obj(config.creds).json(), + **validate_extra_ignore(VastAIBackendConfig, config).model_dump() + ).model_dump_json(), + auth=VastAICreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> VastAIBackendConfigWithCreds: config = self._get_config(record) - return VastAIBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(VastAIBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> VastAIBackendConfig: config = self._get_config(record) - return VastAIBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(VastAIBackendConfig, config) def get_backend(self, record: BackendRecord) -> VastAIBackend: config = self._get_config(record) return VastAIBackend(config=config) def _get_config(self, record: BackendRecord) -> VastAIConfig: - return VastAIConfig.__response__( - **json.loads(record.config), - creds=VastAICreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + VastAIConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(VastAICreds, record.auth), + }, ) def _validate_vastai_creds(self, api_key: str): diff --git a/src/dstack/_internal/core/backends/verda/compute.py b/src/dstack/_internal/core/backends/verda/compute.py index f6cb4c19ac..c8b71b4058 100644 --- a/src/dstack/_internal/core/backends/verda/compute.py +++ b/src/dstack/_internal/core/backends/verda/compute.py @@ -27,7 +27,7 @@ ProvisioningError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel +from dstack._internal.core.models.common import CoreModel, validate_json_extra_ignore from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceConfiguration, @@ -193,7 +193,7 @@ def create_instance( backend_data=VerdaInstanceBackendData( startup_script_id=startup_script_id, ssh_key_ids=ssh_ids, - ).json(), + ).model_dump_json(), ) def terminate_instance( @@ -330,4 +330,4 @@ class VerdaInstanceBackendData(CoreModel): def load(cls, raw: Optional[str]) -> "VerdaInstanceBackendData": if raw is None: return cls() - return cls.__response__.parse_raw(raw) + return validate_json_extra_ignore(cls, raw) diff --git a/src/dstack/_internal/core/backends/verda/configurator.py b/src/dstack/_internal/core/backends/verda/configurator.py index 274c96638d..f427f43691 100644 --- a/src/dstack/_internal/core/backends/verda/configurator.py +++ b/src/dstack/_internal/core/backends/verda/configurator.py @@ -19,6 +19,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore class VerdaConfigurator( @@ -38,27 +39,30 @@ def create_backend( ) -> BackendRecord: return BackendRecord( config=VerdaStoredConfig( - **VerdaBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=VerdaCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(VerdaBackendConfig, config).model_dump() + ).model_dump_json(), + auth=VerdaCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> VerdaBackendConfigWithCreds: config = self._get_config(record) - return VerdaBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(VerdaBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> VerdaBackendConfig: config = self._get_config(record) - return VerdaBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(VerdaBackendConfig, config) def get_backend(self, record: BackendRecord) -> VerdaBackend: config = self._get_config(record) return VerdaBackend(config=config) def _get_config(self, record: BackendRecord) -> VerdaConfig: - return VerdaConfig.__response__( - **json.loads(record.config), - creds=VerdaCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + VerdaConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(VerdaCreds, record.auth), + }, ) def _validate_creds(self, creds: VerdaCreds): diff --git a/src/dstack/_internal/core/backends/vultr/configurator.py b/src/dstack/_internal/core/backends/vultr/configurator.py index 23bde1c381..9d70b2886f 100644 --- a/src/dstack/_internal/core/backends/vultr/configurator.py +++ b/src/dstack/_internal/core/backends/vultr/configurator.py @@ -19,6 +19,7 @@ from dstack._internal.core.models.backends.base import ( BackendType, ) +from dstack._internal.core.models.common import validate_extra_ignore, validate_json_extra_ignore REGIONS = [] @@ -42,27 +43,30 @@ def create_backend( config.regions = REGIONS return BackendRecord( config=VultrStoredConfig( - **VultrBackendConfig.__response__.parse_obj(config).dict() - ).json(), - auth=VultrCreds.parse_obj(config.creds).json(), + **validate_extra_ignore(VultrBackendConfig, config).model_dump() + ).model_dump_json(), + auth=VultrCreds.model_validate(config.creds).model_dump_json(), ) def get_backend_config_with_creds(self, record: BackendRecord) -> VultrBackendConfigWithCreds: config = self._get_config(record) - return VultrBackendConfigWithCreds.__response__.parse_obj(config) + return validate_extra_ignore(VultrBackendConfigWithCreds, config) def get_backend_config_without_creds(self, record: BackendRecord) -> VultrBackendConfig: config = self._get_config(record) - return VultrBackendConfig.__response__.parse_obj(config) + return validate_extra_ignore(VultrBackendConfig, config) def get_backend(self, record: BackendRecord) -> VultrBackend: config = self._get_config(record) return VultrBackend(config=config) def _get_config(self, record: BackendRecord) -> VultrConfig: - return VultrConfig.__response__( - **json.loads(record.config), - creds=VultrCreds.__response__.parse_raw(record.auth), + return validate_extra_ignore( + VultrConfig, + { + **json.loads(record.config), + "creds": validate_json_extra_ignore(VultrCreds, record.auth), + }, ) def _validate_vultr_api_key(self, api_key: str): diff --git a/src/dstack/_internal/core/models/common.py b/src/dstack/_internal/core/models/common.py index 42c56765d2..69e4297dca 100644 --- a/src/dstack/_internal/core/models/common.py +++ b/src/dstack/_internal/core/models/common.py @@ -1,123 +1,118 @@ -import re from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Union - -import orjson -from pydantic import Field -from pydantic_duality import generate_dual_base_model +from typing import Any, Optional, TypeVar, Union, overload + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + TypeAdapter, +) from typing_extensions import Annotated -from dstack._internal.utils.json_utils import pydantic_orjson_dumps +# pydantic v2 generates draft 2020-12. The published `configuration.json` / `profiles.json` +# advertise the dialect they were generated for, so this has to move with pydantic. +JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema" + + +def drop_merged_profile(schema: dict[str, Any]) -> None: + """ + `json_schema_extra` hook for the specs carrying a `merged_profile`. + + It is an internal field computed from the configuration and the profile, never written by a + user, so it must not appear in the published schema. + """ + schema.get("properties", {}).pop("merged_profile", None) + +# Mirrors pydantic v2's `IncEx`, so these can be passed straight to `model_dump`/`model_copy`. +# v2 keys a mapping by int or str but not both, and its values are `IncEx | bool`. IncludeExcludeFieldType = Union[int, str] -IncludeExcludeSetType = set[IncludeExcludeFieldType] -IncludeExcludeDictType = dict[ - IncludeExcludeFieldType, Union[bool, IncludeExcludeSetType, "IncludeExcludeDictType"] +IncludeExcludeSetType = Union[set[int], set[str]] +# `dict` rather than `Mapping`, so these stay assignable both *to* pydantic's `IncEx` and to the +# plain `Dict` parameters the plugin API declares. Keyed by int or str but not both, like `IncEx`. +IncludeExcludeDictType = Union[ + dict[int, Union["IncludeExcludeType", bool]], + dict[str, Union["IncludeExcludeType", bool]], ] IncludeExcludeType = Union[IncludeExcludeSetType, IncludeExcludeDictType] -class CoreConfig: - json_loads = orjson.loads - json_dumps = pydantic_orjson_dumps - - -# All dstack models inherit from pydantic-duality's DualBaseModel. -# DualBaseModel creates two classes for the model: -# one with extra = "forbid" (CoreModel/CoreModel.__request__), -# and another with extra = "ignore" (CoreModel.__response__). -# This allows to use the same model both for strict parsing of the user input and -# for permissive parsing of the server responses. -# -# We define a func to generate CoreModel dynamically that can be used -# to define custom Config for both __request__ and __response__ models. -# Note: Defining config in the model class directly overrides -# pydantic-duality's base config, breaking __response__. -def generate_dual_core_model( - custom_config: Union[type, Mapping], -) -> "type[CoreModel]": - class CoreModel(generate_dual_base_model(custom_config)): - def json( - self, - *, - include: Optional[IncludeExcludeType] = None, - exclude: Optional[IncludeExcludeType] = None, - by_alias: bool = False, - skip_defaults: Optional[bool] = None, # ignore as it's deprecated - exclude_unset: bool = False, - exclude_defaults: bool = False, - exclude_none: bool = False, - encoder: Optional[Callable[[Any], Any]] = None, - models_as_dict: bool = True, # does not seems to be needed by dstack or dependencies - **dumps_kwargs: Any, - ) -> str: - """ - Override `json()` method so that it calls `dict()`. - Allows changing how models are serialized by overriding `dict()` only. - By default, `json()` won't call `dict()`, so changes applied in `dict()` won't take place. - """ - data = self.dict( - by_alias=by_alias, - include=include, - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) - if self.__custom_root_type__: - data = data["__root__"] - return self.__config__.json_dumps(data, default=encoder, **dumps_kwargs) - - return CoreModel - - -if TYPE_CHECKING: - - class CoreModel(generate_dual_base_model(CoreConfig)): - pass -else: - CoreModel = generate_dual_core_model(CoreConfig) - - -class FrozenConfig(CoreConfig): - frozen = True - - -FrozenCoreModel = generate_dual_core_model(FrozenConfig) - - -class Duration(int): +class CoreModel(BaseModel): """ - Duration in seconds. + The base class for all dstack models. + + Unknown fields are rejected, which is what makes `dstack apply` report a typo'd key in a + user's YAML and what makes the API reject an unexpected request body. Reading a stored blob + or a peer's response needs the opposite — see `validate_extra_ignore` below. """ - @classmethod - def __get_validators__(cls): - yield cls.parse + model_config = ConfigDict( + extra="forbid", + # YAML numbers reach str fields as int/float, e.g. a `python: 3.10` style shorthand. + coerce_numbers_to_str=True, + ) - @classmethod - def parse(cls, v: Union[int, str]) -> "Duration": - if isinstance(v, (int, float)): - return cls(v) - if isinstance(v, str): - try: - return cls(int(v)) - except ValueError: - pass - regex = re.compile(r"(?P\d+) *(?P[smhdw])$") - re_match = regex.match(v) - if not re_match: - raise ValueError(f"Cannot parse the duration {v}") - amount, unit = int(re_match.group("amount")), re_match.group("unit") - multiplier = { - "s": 1, - "m": 60, - "h": 3600, - "d": 24 * 3600, - "w": 7 * 24 * 3600, - }[unit] - return cls(amount * multiplier) - raise ValueError(f"Cannot parse the duration {v}") + +class FrozenCoreModel(CoreModel): + model_config = ConfigDict(frozen=True) + + +T = TypeVar("T") + +_type_adapters: dict[Any, TypeAdapter] = {} + + +@overload +def validate_extra_ignore(tp: type[T], obj: Any) -> T: ... + + +@overload +def validate_extra_ignore(tp: Any, obj: Any) -> Any: ... + + +def validate_extra_ignore(tp: Any, obj: Any) -> Any: + """ + Validate `obj` against `tp` with `extra="ignore"`, dropping unknown fields at every level. + + This is the read path: anything decoded from a stored blob or from a peer's response goes + through here, so that a newer writer adding a field does not break an older reader. + + `obj` may be an instance of a *different* model class, which is how the backend configurators + re-read an `AWSBackendConfigWithCreds` as an `AWSConfig`. v1's `parse_obj` accepted that + directly; v2 needs a dict, so dump first. + """ + if isinstance(obj, BaseModel): + obj = obj.model_dump() + return _get_type_adapter(tp).validate_python(obj, extra="ignore") + + +@overload +def validate_json_extra_ignore(tp: type[T], data: Union[str, bytes]) -> T: ... + + +@overload +def validate_json_extra_ignore(tp: Any, data: Union[str, bytes]) -> Any: ... + + +def validate_json_extra_ignore(tp: Any, data: Union[str, bytes]) -> Any: + """ + The JSON-input counterpart of `validate_extra_ignore`, keeping native JSON parsing rather + than going through `json.loads` and then validating in Python mode. + """ + return _get_type_adapter(tp).validate_json(data, extra="ignore") + + +def _get_type_adapter(tp: Any) -> TypeAdapter: + # Constructing a TypeAdapter builds a schema and a validator, so reuse them per type. + try: + adapter = _type_adapters.get(tp) + except TypeError: + # An unhashable annotation cannot be cached. Rare enough not to matter. + return TypeAdapter(tp) + if adapter is None: + adapter = TypeAdapter(tp) + _type_adapters[tp] = adapter + return adapter class RegistryAuth(FrozenCoreModel): diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py index 17cf55f71c..bb81c37ed0 100644 --- a/src/dstack/_internal/core/models/configurations.py +++ b/src/dstack/_internal/core/models/configurations.py @@ -5,29 +5,38 @@ from pathlib import PurePosixPath from typing import Annotated, Any, Dict, List, Literal, Optional, Union -import orjson -from pydantic import Field, ValidationError, conint, constr, root_validator, validator +from pydantic import ( + BeforeValidator, + ConfigDict, + Field, + GetCoreSchemaHandler, + RootModel, + ValidationError, + ValidationInfo, + conint, + constr, + field_validator, + model_validator, +) +from pydantic_core import CoreSchema, core_schema from typing_extensions import Self from dstack._internal.core.errors import ConfigurationError from dstack._internal.core.models.common import ( - CoreConfig, + JSON_SCHEMA_DIALECT, CoreModel, - Duration, EntityReference, RegistryAuth, - generate_dual_core_model, + validate_extra_ignore, ) +from dstack._internal.core.models.duration import Duration, parse_off_duration from dstack._internal.core.models.envs import Env from dstack._internal.core.models.files import FilePathMapping from dstack._internal.core.models.fleets import FleetConfiguration from dstack._internal.core.models.gateways import GatewayConfiguration from dstack._internal.core.models.profiles import ( ProfileParams, - ProfileParamsConfig, SpotPolicy, - parse_duration, - parse_off_duration, ) from dstack._internal.core.models.resources import Range, ResourcesSpec from dstack._internal.core.models.routers import AnyServiceRouterConfig, ReplicaGroupRouterConfig @@ -44,10 +53,6 @@ from dstack._internal.core.services import is_valid_replica_group_name from dstack._internal.proxy.gateway.const import SERVICE_SCALING_WINDOWS from dstack._internal.utils.common import has_duplicates, list_enum_values_for_annotation -from dstack._internal.utils.json_schema import add_extra_schema_types -from dstack._internal.utils.json_utils import ( - pydantic_orjson_dumps_with_indent, -) CommandsList = List[str] ValidPort = conint(gt=0, le=65536) @@ -86,6 +91,7 @@ class PythonVersion(str, Enum): PY311 = "3.11" PY312 = "3.12" PY313 = "3.13" + PY314 = "3.14" class PortMapping(CoreModel): @@ -167,6 +173,25 @@ class RepoSpec(CoreModel): ), ] = RepoExistsAction.ERROR + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls._parse_shorthand, + model_schema, + json_schema_input_schema=core_schema.union_schema( + [model_schema, core_schema.str_schema()] + ), + ) + + @classmethod + def _parse_shorthand(cls, v: Any) -> Any: + if isinstance(v, str): + return cls.parse(v) + return v + @classmethod def parse(cls, v: str) -> Self: is_url = False @@ -193,15 +218,16 @@ def parse(cls, v: str) -> Self: return cls(local_path=parts[0], path=parts[1]) raise ValueError(f"Invalid repo: {v}") - @root_validator - def validate_local_path_or_url(cls, values): - if values["local_path"] and values["url"]: + @model_validator(mode="after") + def validate_local_path_or_url(self) -> Self: + if self.local_path and self.url: raise ValueError("`local_path` and `url` are mutually exclusive") - if not values["local_path"] and not values["url"]: + if not self.local_path and not self.url: raise ValueError("Either `local_path` or `url` must be specified") - return values + return self - @validator("path") + @field_validator("path") + @classmethod def validate_path(cls, v: Optional[str]) -> Optional[str]: if v is None: return v @@ -256,7 +282,8 @@ class ScalingSpec(CoreModel): ), ] = Duration.parse("10m") - @validator("window") + @field_validator("window") + @classmethod def validate_window(cls, v: Optional[Duration]) -> Optional[Duration]: if v is not None and v not in SERVICE_SCALING_WINDOWS: raise ValueError(f"Window must be one of: {ALLOWED_SCALING_WINDOWS_DESCRIPTION}") @@ -273,7 +300,7 @@ class HeaderPartitioningKey(CoreModel): str, Field( description="Name of the header to use for partitioning", - regex=r"^[a-zA-Z0-9-_]+$", # prevent Nginx config injection + pattern=r"^[a-zA-Z0-9-_]+$", # prevent Nginx config injection max_length=500, # chosen randomly, Nginx limit is higher ), ] @@ -288,7 +315,7 @@ class RateLimit(CoreModel): " If an incoming request matches several prefixes, the longest prefix is applied" ), max_length=4094, # Nginx limit - regex=r"^/[^\s\\{}]*$", # prevent Nginx config injection + pattern=r"^/[^\s\\{}]*$", # prevent Nginx config injection ), ] = "/" key: Annotated[ @@ -349,20 +376,7 @@ class HTTPHeaderSpec(CoreModel): ] -class ProbeConfigConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["timeout"], - extra_types=[{"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["interval"], - extra_types=[{"type": "string"}], - ) - - -class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)): +class ProbeConfig(CoreModel): type: Annotated[ Literal["http"], Field(description="The probe type. Must be `http`"), @@ -381,7 +395,7 @@ class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)): ] = None headers: Annotated[ list[HTTPHeaderSpec], - Field(description="A list of HTTP headers to include in the request", max_items=16), + Field(description="A list of HTTP headers to include in the request", max_length=16), ] = [] body: Annotated[ Optional[str], @@ -392,7 +406,7 @@ class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)): ), ] = None timeout: Annotated[ - Optional[int], + Optional[Duration], Field( description=( f"Maximum amount of time the HTTP request is allowed to take. Defaults to `{DEFAULT_PROBE_TIMEOUT}s`" @@ -400,7 +414,7 @@ class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)): ), ] = None interval: Annotated[ - Optional[int], + Optional[Duration], Field( description=( "Minimum amount of time between the end of one probe execution" @@ -430,25 +444,22 @@ class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)): ), ] = None - @validator("timeout", pre=True) - def parse_timeout(cls, v: Optional[Union[int, str]]) -> Optional[int]: - if v is None: - return v - parsed = parse_duration(v) - if parsed < MIN_PROBE_TIMEOUT: + @field_validator("timeout") + @classmethod + def validate_timeout(cls, v: Optional[Duration]) -> Optional[Duration]: + if v is not None and v < MIN_PROBE_TIMEOUT: raise ValueError(f"Probe timeout cannot be shorter than {MIN_PROBE_TIMEOUT}s") - return parsed + return v - @validator("interval", pre=True) - def parse_interval(cls, v: Optional[Union[int, str]]) -> Optional[int]: - if v is None: - return v - parsed = parse_duration(v) - if parsed < MIN_PROBE_INTERVAL: + @field_validator("interval") + @classmethod + def validate_interval(cls, v: Optional[Duration]) -> Optional[Duration]: + if v is not None and v < MIN_PROBE_INTERVAL: raise ValueError(f"Probe interval cannot be shorter than {MIN_PROBE_INTERVAL}s") - return parsed + return v - @validator("url") + @field_validator("url") + @classmethod def validate_url(cls, v: Optional[str]) -> Optional[str]: if v is None: return v @@ -460,25 +471,45 @@ def validate_url(cls, v: Optional[str]) -> Optional[str]: raise ValueError("Cannot contain non-printable characters") return v - @root_validator - def validate_body_matches_method(cls, values): - method: HTTPMethod = values["method"] - if values["body"] is not None and method in ["get", "head"]: + @model_validator(mode="after") + def validate_body_matches_method(self) -> Self: + method: HTTPMethod = self.method + if self.body is not None and method in ["get", "head"]: raise ValueError(f"Cannot set request body for the `{method}` method") - return values + return self -class BaseRunConfigurationConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["volumes"]["items"], - extra_types=[{"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["files"]["items"], - extra_types=[{"type": "string"}], - ) +def _parse_mount_point_shorthand(v: Union[MountPoint, str]) -> MountPoint: + if isinstance(v, str): + return parse_mount_point(v) + return v + + +def _parse_port_shorthand(v: Union[int, str, PortMapping]) -> PortMapping: + if isinstance(v, int): + return PortMapping(local_port=v, container_port=v) + if isinstance(v, str): + return PortMapping.parse(v) + return v + + +# `json_schema_input_type` keeps the shorthand visible in the generated JSON Schema, which used to +# be patched in per field by a sibling config class. +MountPointOrShorthand = Annotated[ + MountPoint, + BeforeValidator(_parse_mount_point_shorthand, json_schema_input_type=Union[MountPoint, str]), +] +# Declared as `PortMapping` rather than the input union: that is what the value always is once +# `_parse_port_shorthand` has run. +PortMappingOrShorthand = Annotated[ + PortMapping, + BeforeValidator( + _parse_port_shorthand, + json_schema_input_type=Union[ + ValidPort, constr(pattern=r"^(?:[0-9]+|\*):[0-9]+$"), PortMapping + ], + ), +] class BaseRunConfiguration(CoreModel): @@ -575,7 +606,9 @@ class BaseRunConfiguration(CoreModel): ), ), ] = None - volumes: Annotated[List[MountPoint], Field(description="The volumes mount points")] = [] + volumes: Annotated[ + List[MountPointOrShorthand], Field(description="The volumes mount points") + ] = [] docker: Annotated[ Optional[bool], Field( @@ -605,9 +638,10 @@ class BaseRunConfiguration(CoreModel): dev environments it runs right before `init`. """ - @validator("python", pre=True, always=True) - def convert_python(cls, v, values) -> Optional[PythonVersion]: - if v is not None and values.get("image"): + @field_validator("python", mode="before") + @classmethod + def convert_python(cls, v, info: ValidationInfo) -> Optional[PythonVersion]: + if v is not None and info.data.get("image"): raise ValueError("`image` and `python` are mutually exclusive fields") if isinstance(v, float): v = str(v) @@ -617,50 +651,36 @@ def convert_python(cls, v, values) -> Optional[PythonVersion]: return PythonVersion(v) return v - @validator("docker", pre=True, always=True) - def _docker(cls, v, values) -> Optional[bool]: - if v is True and values.get("image"): + @field_validator("docker", mode="before") + @classmethod + def _docker(cls, v, info: ValidationInfo) -> Optional[bool]: + if v is True and info.data.get("image"): raise ValueError("`image` and `docker` are mutually exclusive fields") - if v is True and values.get("python"): + if v is True and info.data.get("python"): raise ValueError("`python` and `docker` are mutually exclusive fields") - if v is True and values.get("nvcc"): + if v is True and info.data.get("nvcc"): raise ValueError("`nvcc` and `docker` are mutually exclusive fields") # Ideally, we'd like to also prohibit privileged=False when docker=True, # but it's not possible to do so without breaking backwards compatibility. return v - @validator("volumes", each_item=True, pre=True) - def convert_volumes(cls, v: Union[MountPoint, str]) -> MountPoint: - if isinstance(v, str): - return parse_mount_point(v) - return v - - @validator("files", each_item=True, pre=True) - def convert_files(cls, v: Union[FilePathMapping, str]) -> FilePathMapping: - if isinstance(v, str): - return FilePathMapping.parse(v) - return v - - @validator("repos", pre=True, each_item=True) - def convert_repos(cls, v: Union[RepoSpec, str]) -> RepoSpec: - if isinstance(v, str): - return RepoSpec.parse(v) - return v - - @validator("repos") + @field_validator("repos") + @classmethod def validate_repos(cls, v) -> RepoSpec: if len(v) > 1: raise ValueError("A maximum of one repo is currently supported") return v - @validator("user") + @field_validator("user") + @classmethod def validate_user(cls, v) -> Optional[str]: if v is None: return None UnixUser.parse(v) return v - @validator("shell") + @field_validator("shell") + @classmethod def validate_shell(cls, v) -> Optional[str]: if v is None: return None @@ -674,32 +694,24 @@ def validate_shell(cls, v) -> Optional[str]: class ConfigurationWithPortsParams(CoreModel): ports: Annotated[ - List[Union[ValidPort, constr(regex=r"^(?:[0-9]+|\*):[0-9]+$"), PortMapping]], + List[PortMappingOrShorthand], Field(description="Port numbers/mapping to expose"), ] = [] - @validator("ports", each_item=True) - def convert_ports(cls, v) -> PortMapping: - if isinstance(v, int): - return PortMapping(local_port=v, container_port=v) - elif isinstance(v, str): - return PortMapping.parse(v) - return v - class ConfigurationWithCommandsParams(CoreModel): commands: Annotated[CommandsList, Field(description="The shell commands to run")] = [] - @root_validator - def check_image_or_commands_present(cls, values): + @model_validator(mode="after") + def check_image_or_commands_present(self) -> Self: # If replicas is list, skip validation - commands come from replica groups - replicas = values.get("replicas") + replicas = getattr(self, "replicas", None) if isinstance(replicas, list): - return values + return self - if not values.get("commands") and not values.get("image"): + if not self.commands and not getattr(self, "image", None): raise ValueError("Either `commands` or `image` must be set") - return values + return self class DevEnvironmentConfigurationParams(CoreModel): @@ -717,7 +729,7 @@ class DevEnvironmentConfigurationParams(CoreModel): ] = None init: Annotated[CommandsList, Field(description="The shell commands to run on startup")] = [] inactivity_duration: Annotated[ - Optional[Union[Literal["off"], int, bool, str]], + Optional[int], Field( description=( "The maximum amount of time the dev environment can be inactive" @@ -732,7 +744,13 @@ class DevEnvironmentConfigurationParams(CoreModel): ), ] = None - @validator("inactivity_duration", pre=True, allow_reuse=True) + # Not `OptionalOffableDuration`: "off" collapses to `None` here rather than staying as the string. + @field_validator( + "inactivity_duration", + mode="before", + json_schema_input_type=Optional[Union[Literal["off"], int, bool, str]], + ) + @classmethod def parse_inactivity_duration( cls, v: Optional[Union[Literal["off"], int, bool, str]] ) -> Optional[int]: @@ -741,10 +759,10 @@ def parse_inactivity_duration( return v return None - @root_validator - def validate_ide_and_version(cls, values): - ide = values.get("ide") - version = values.get("version") + @model_validator(mode="after") + def validate_ide_and_version(self) -> Self: + ide = self.ide + version = self.version if version and ide is None: raise ValueError("`version` requires `ide` to be set") if ide == "windsurf" and version: @@ -754,17 +772,7 @@ def validate_ide_and_version(cls, values): f"Invalid Windsurf version format: `{version}`. " "Expected format: `version@commit` (e.g., `1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d`)" ) - return values - - -class DevEnvironmentConfigurationConfig( - ProfileParamsConfig, - BaseRunConfigurationConfig, -): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - ProfileParamsConfig.schema_extra(schema) - BaseRunConfigurationConfig.schema_extra(schema) + return self class DevEnvironmentConfiguration( @@ -772,62 +780,38 @@ class DevEnvironmentConfiguration( BaseRunConfiguration, ConfigurationWithPortsParams, DevEnvironmentConfigurationParams, - generate_dual_core_model(DevEnvironmentConfigurationConfig), ): type: Literal["dev-environment"] = "dev-environment" - @validator("entrypoint") + @field_validator("entrypoint") + @classmethod def validate_entrypoint(cls, v: Optional[str]) -> Optional[str]: if v is not None: raise ValueError("entrypoint is not supported for dev-environment") return v - @root_validator - def validate_dstack_and_inactivity_duration(cls, values): - if values.get("dstack") and values.get("inactivity_duration") is not None: + @model_validator(mode="after") + def validate_dstack_and_inactivity_duration(self) -> Self: + if self.dstack and self.inactivity_duration is not None: # The persistent server connection counts as activity, so inactivity is never detected raise ValueError("`dstack` is not supported together with `inactivity_duration`") - return values + return self class TaskConfigurationParams(CoreModel): nodes: Annotated[int, Field(description="Number of nodes", ge=1)] = 1 -class TaskConfigurationConfig( - ProfileParamsConfig, - BaseRunConfigurationConfig, -): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - ProfileParamsConfig.schema_extra(schema) - BaseRunConfigurationConfig.schema_extra(schema) - - class TaskConfiguration( ProfileParams, BaseRunConfiguration, ConfigurationWithCommandsParams, ConfigurationWithPortsParams, TaskConfigurationParams, - generate_dual_core_model(TaskConfigurationConfig), ): type: Literal["task"] = "task" -class ServiceConfigurationParamsConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["replicas"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["model"], - extra_types=[{"type": "string"}], - ) - - def _validate_replica_range(v: Range[int]) -> Range[int]: """Validate a Range[int] used for replica counts.""" if v.max is None: @@ -925,20 +909,23 @@ class ReplicaGroup(CoreModel): ), ] = None - @validator("name") + @field_validator("name") + @classmethod def validate_name(cls, v: Optional[str]) -> Optional[str]: if v is not None: if not is_valid_replica_group_name(v): raise ValueError("Resource name should match regex '^[a-z0-9][a-z0-9-]{0,39}$'") return v - @validator("count") + @field_validator("count") + @classmethod def convert_count(cls, v: Range[int]) -> Range[int]: return _validate_replica_range(v) - @validator("python", pre=True, always=True) - def convert_python(cls, v, values) -> Optional[PythonVersion]: - if v is not None and values.get("image"): + @field_validator("python", mode="before") + @classmethod + def convert_python(cls, v, info: ValidationInfo) -> Optional[PythonVersion]: + if v is not None and info.data.get("image"): raise ValueError("`image` and `python` are mutually exclusive within a replica group") if isinstance(v, float): v = str(v) @@ -948,45 +935,47 @@ def convert_python(cls, v, values) -> Optional[PythonVersion]: return PythonVersion(v) return v - @validator("docker", pre=True, always=True) - def _docker(cls, v, values) -> Optional[bool]: - if v is True and values.get("image"): + @field_validator("docker", mode="before") + @classmethod + def _docker(cls, v, info: ValidationInfo) -> Optional[bool]: + if v is True and info.data.get("image"): raise ValueError("`image` and `docker` are mutually exclusive within a replica group") - if v is True and values.get("python"): + if v is True and info.data.get("python"): raise ValueError("`python` and `docker` are mutually exclusive within a replica group") - if v is True and values.get("nvcc"): + if v is True and info.data.get("nvcc"): raise ValueError("`nvcc` and `docker` are mutually exclusive within a replica group") return v - @validator("privileged", pre=True, always=True) - def _privileged(cls, v, values) -> Optional[bool]: + @field_validator("privileged", mode="before") + @classmethod + def _privileged(cls, v, info: ValidationInfo) -> Optional[bool]: # Docker-in-docker requires privileged mode. The service level # cannot enforce this rule because its `privileged` field defaults # to `False` (existing backwards-compatibility constraint), so it # cannot distinguish "unset" from explicit `False`. At the group # level we keep `privileged` as `Optional[bool] = None`, so we can. - if v is False and values.get("docker") is True: + if v is False and info.data.get("docker") is True: raise ValueError( "`privileged: false` is incompatible with `docker: true` within " "a replica group (docker-in-docker requires privileged mode)" ) return v - @root_validator() - def validate_scaling(cls, values): - scaling = values.get("scaling") - count = values.get("count") + @model_validator(mode="after") + def validate_scaling(self) -> Self: + scaling = self.scaling + count = self.count if count and count.min != count.max and not scaling: raise ValueError("When you set `count` to a range, ensure to specify `scaling`.") if count and count.min == count.max and scaling: raise ValueError("To use `scaling`, `count` must be set to a range.") - return values + return self class ServiceConfigurationParams(CoreModel): port: Annotated[ # NOTE: it's a PortMapping for historical reasons. Only `port.container_port` is used. - Union[ValidPort, constr(regex=r"^[0-9]+:[0-9]+$"), PortMapping], + Union[ValidPort, constr(pattern=r"^[0-9]+:[0-9]+$"), PortMapping], Field(description="The port the application listens on"), ] gateway: Annotated[ @@ -998,6 +987,7 @@ class ServiceConfigurationParams(CoreModel): ] ], Field( + union_mode="left_to_right", # preserving pydantic v1 parsing behavior description=( "The name of the gateway. Specify boolean `false` to run without a gateway." " Specify boolean `true` to run with the default gateway." @@ -1077,7 +1067,8 @@ class ServiceConfigurationParams(CoreModel): ), ] = None - @validator("port") + @field_validator("port") + @classmethod def convert_port(cls, v) -> PortMapping: if isinstance(v, int): return PortMapping(local_port=80, container_port=v) @@ -1085,13 +1076,15 @@ def convert_port(cls, v) -> PortMapping: return PortMapping.parse(v) return v - @validator("model", pre=True) + @field_validator("model", mode="before", json_schema_input_type=Optional[Union[AnyModel, str]]) + @classmethod def convert_model(cls, v: Optional[Union[AnyModel, str]]) -> Optional[AnyModel]: if isinstance(v, str): return OpenAIChatModel(type="chat", name=v, format="openai") return v - @validator("rate_limits") + @field_validator("rate_limits") + @classmethod def validate_rate_limits(cls, v: list[RateLimit]) -> list[RateLimit]: counts = Counter(limit.prefix for limit in v) duplicates = [prefix for prefix, count in counts.items() if count > 1] @@ -1102,7 +1095,8 @@ def validate_rate_limits(cls, v: list[RateLimit]) -> list[RateLimit]: ) return v - @validator("probes") + @field_validator("probes") + @classmethod def validate_probes(cls, v: Optional[list[ProbeConfig]]) -> Optional[list[ProbeConfig]]: if v is None: return v @@ -1114,7 +1108,8 @@ def validate_probes(cls, v: Optional[list[ProbeConfig]]) -> Optional[list[ProbeC raise ValueError("Probes must be unique") return v - @validator("gateway") + @field_validator("gateway") + @classmethod def validate_gateway( cls, v: Optional[Union[bool, EntityReference, str]] ) -> Optional[Union[bool, EntityReference]]: @@ -1122,7 +1117,8 @@ def validate_gateway( return EntityReference.parse(v) return v - @validator("replicas") + @field_validator("replicas") + @classmethod def validate_replicas( cls, v: Optional[Union[Range[int], List[ReplicaGroup]]] ) -> Optional[Union[Range[int], List[ReplicaGroup]]]: @@ -1150,10 +1146,10 @@ def validate_replicas( ) return v - @root_validator() - def validate_scaling(cls, values): - scaling = values.get("scaling") - replicas = values.get("replicas") + @model_validator(mode="after") + def validate_scaling(self) -> Self: + scaling = self.scaling + replicas = self.replicas if isinstance(replicas, Range): if replicas and replicas.min != replicas.max and not scaling: @@ -1162,80 +1158,80 @@ def validate_scaling(cls, values): ) if replicas and replicas.min == replicas.max and scaling: raise ValueError("To use `scaling`, `replicas` must be set to a range.") - return values + return self - @root_validator() - def validate_top_level_properties_with_replica_groups(cls, values): + @model_validator(mode="after") + def validate_top_level_properties_with_replica_groups(self) -> Self: """ When replicas is a list of ReplicaGroup, forbid top-level scaling and commands. """ - replicas = values.get("replicas") + replicas = self.replicas if not isinstance(replicas, list): - return values + return self - scaling = values.get("scaling") + scaling = self.scaling if scaling is not None: raise ValueError( "Top-level `scaling` is not allowed when `replicas` is a list. " "Specify `scaling` in each replica group instead." ) - commands = values.get("commands", []) + commands = getattr(self, "commands", None) if commands: raise ValueError( "Top-level `commands` is not allowed when `replicas` is a list. " "Specify `commands` in each replica group instead." ) - return values + return self - @root_validator() - def validate_no_mixed_service_and_group_container_fields(cls, values): + @model_validator(mode="after") + def validate_no_mixed_service_and_group_container_fields(self) -> Self: """ When replicas is a list, certain fields may be set at the service level OR in replica groups, never both. Mixing is rejected — including partial mixing, where only some groups set a field the service also sets — because it leaves precedence ambiguous. """ - replicas = values.get("replicas") + replicas = self.replicas if not isinstance(replicas, list): - return values + return self checks = [ ( "image", - values.get("image") is not None, + getattr(self, "image", None) is not None, lambda g: g.image is not None, ), ( "docker", - values.get("docker") is True, + getattr(self, "docker", None) is True, lambda g: g.docker is not None, ), ( "privileged", - values.get("privileged") is True, + getattr(self, "privileged", None) is True, lambda g: g.privileged is not None, ), ( "python", - values.get("python") is not None, + getattr(self, "python", None) is not None, lambda g: g.python is not None, ), ( "nvcc", - values.get("nvcc") is True, + getattr(self, "nvcc", None) is True, lambda g: g.nvcc is not None, ), ( "spot_policy", - values.get("spot_policy") is not None, + getattr(self, "spot_policy", None) is not None, lambda g: g.spot_policy is not None, ), ( "reservation", - values.get("reservation") is not None, + getattr(self, "reservation", None) is not None, lambda g: g.reservation is not None, ), ] @@ -1250,29 +1246,74 @@ def validate_no_mixed_service_and_group_container_fields(cls, values): f"place only — either at the service level (all groups " f"inherit) or per group, but not both." ) - return values + return self - @root_validator() - def validate_no_conflicting_image_sources_across_levels(cls, values): + @model_validator(mode="after") + def validate_no_conflicting_image_sources_across_levels(self) -> Self: """ Image-source fields (`image`, `docker`, `python`, `nvcc`) cannot be mixed across service and group levels in conflicting ways. """ - replicas = values.get("replicas") + replicas = self.replicas if not isinstance(replicas, list): - return values + return self forbidden = [ - ("image", values.get("image") is not None, "docker", lambda g: g.docker is not None), - ("image", values.get("image") is not None, "python", lambda g: g.python is not None), - ("image", values.get("image") is not None, "nvcc", lambda g: g.nvcc is not None), - ("docker", values.get("docker") is True, "image", lambda g: g.image is not None), - ("docker", values.get("docker") is True, "python", lambda g: g.python is not None), - ("docker", values.get("docker") is True, "nvcc", lambda g: g.nvcc is not None), - ("python", values.get("python") is not None, "image", lambda g: g.image is not None), - ("python", values.get("python") is not None, "docker", lambda g: g.docker is not None), - ("nvcc", values.get("nvcc") is True, "image", lambda g: g.image is not None), - ("nvcc", values.get("nvcc") is True, "docker", lambda g: g.docker is not None), + ( + "image", + getattr(self, "image", None) is not None, + "docker", + lambda g: g.docker is not None, + ), + ( + "image", + getattr(self, "image", None) is not None, + "python", + lambda g: g.python is not None, + ), + ( + "image", + getattr(self, "image", None) is not None, + "nvcc", + lambda g: g.nvcc is not None, + ), + ( + "docker", + getattr(self, "docker", None) is True, + "image", + lambda g: g.image is not None, + ), + ( + "docker", + getattr(self, "docker", None) is True, + "python", + lambda g: g.python is not None, + ), + ( + "docker", + getattr(self, "docker", None) is True, + "nvcc", + lambda g: g.nvcc is not None, + ), + ( + "python", + getattr(self, "python", None) is not None, + "image", + lambda g: g.image is not None, + ), + ( + "python", + getattr(self, "python", None) is not None, + "docker", + lambda g: g.docker is not None, + ), + ("nvcc", getattr(self, "nvcc", None) is True, "image", lambda g: g.image is not None), + ( + "nvcc", + getattr(self, "nvcc", None) is True, + "docker", + lambda g: g.docker is not None, + ), ] for s_field, s_set, g_field, g_pred in forbidden: @@ -1284,22 +1325,22 @@ def validate_no_conflicting_image_sources_across_levels(cls, values): f"`{g_field}` in replica group(s) {conflicting}. " f"These image-source fields are mutually exclusive." ) - return values + return self - @root_validator() - def validate_replica_groups_have_commands_or_image(cls, values): + @model_validator(mode="after") + def validate_replica_groups_have_commands_or_image(self) -> Self: """ When replicas is a list, ensure each ReplicaGroup has something to run. Mirrors the service-level rule: either explicit `commands` or an `image` (group-level or service-level) is required. """ - replicas = values.get("replicas") + replicas = self.replicas if not isinstance(replicas, list): - return values + return self - service_has_image = values.get("image") is not None + service_has_image = getattr(self, "image", None) is not None for group in replicas: if not group.commands and group.image is None and not service_has_image: @@ -1309,13 +1350,13 @@ def validate_replica_groups_have_commands_or_image(cls, values): "service level." ) - return values + return self - @root_validator() - def validate_at_most_one_router_replica_group(cls, values): - replicas = values.get("replicas") + @model_validator(mode="after") + def validate_at_most_one_router_replica_group(self) -> Self: + replicas = self.replicas if not isinstance(replicas, list): - return values + return self router_groups = [g for g in replicas if g.router is not None] if len(router_groups) > 1: raise ValueError("At most one replica group may specify `router`.") @@ -1323,36 +1364,24 @@ def validate_at_most_one_router_replica_group(cls, values): router_group = router_groups[0] if router_group.count.min != 1 or router_group.count.max != 1: raise ValueError("For now replica group with `router` must have `count: 1`.") - return values + return self - @root_validator() - def validate_replica_group_router_mutex(cls, values): + @model_validator(mode="after") + def validate_replica_group_router_mutex(self) -> Self: """ When a replica group sets `router:`, service-level `router` must be omitted. (Gateway-level SGLang is rejected at service registration when a gateway is selected.) """ - replicas = values.get("replicas") + replicas = self.replicas if not isinstance(replicas, list): - return values + return self if not any(g.router is not None for g in replicas): - return values - if values.get("router") is not None: + return self + if self.router is not None: raise ValueError( "Service-Level router configuration is not allowed together with replica-group `router`." ) - return values - - -class ServiceConfigurationConfig( - ProfileParamsConfig, - BaseRunConfigurationConfig, - ServiceConfigurationParamsConfig, -): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - ProfileParamsConfig.schema_extra(schema) - BaseRunConfigurationConfig.schema_extra(schema) - ServiceConfigurationParamsConfig.schema_extra(schema) + return self class ServiceConfiguration( @@ -1360,7 +1389,6 @@ class ServiceConfiguration( BaseRunConfiguration, ConfigurationWithCommandsParams, ServiceConfigurationParams, - generate_dual_core_model(ServiceConfigurationConfig), ): type: Literal["service"] = "service" @@ -1396,16 +1424,13 @@ def replica_groups(self) -> List[ReplicaGroup]: AnyRunConfiguration = Union[DevEnvironmentConfiguration, TaskConfiguration, ServiceConfiguration] -class RunConfiguration(CoreModel): - __root__: Annotated[ - AnyRunConfiguration, - Field(discriminator="type"), - ] +class RunConfiguration(RootModel[Annotated[AnyRunConfiguration, Field(discriminator="type")]]): + pass def parse_run_configuration(data: dict) -> AnyRunConfiguration: try: - conf = RunConfiguration.parse_obj(data).__root__ + conf = RunConfiguration.model_validate(data).root except ValidationError as e: raise ConfigurationError(e) return conf @@ -1428,7 +1453,20 @@ class ApplyConfigurationType(str, Enum): ] -class BaseApplyConfiguration(CoreModel): +_AnyBaseApplyConfiguration = Annotated[ + Union[ + # Final configurations + AnyRunConfiguration, + FleetConfiguration, + GatewayConfiguration, + # Base configurations (further parsing required to get a concrete AnyApplyConfiguration) + BaseVolumeConfiguration, + ], + Field(discriminator="type"), +] + + +class BaseApplyConfiguration(RootModel[_AnyBaseApplyConfiguration]): """ `BaseApplyConfiguration` parses the configuration based on the `type` discriminator field, but further dispatching (reparsing) may be required if there is another discriminator field, @@ -1438,28 +1476,16 @@ class BaseApplyConfiguration(CoreModel): Don't use this model directly, use `parse_apply_configuration()` instead. """ - __root__: Annotated[ - Union[ - # Final configurations - AnyRunConfiguration, - FleetConfiguration, - GatewayConfiguration, - # Base configurations (further parsing required to get a concrete AnyApplyConfiguration) - BaseVolumeConfiguration, - ], - Field(discriminator="type"), - ] - def parse_apply_configuration(data: dict) -> AnyApplyConfiguration: try: # First-pass parsing ignoring extra fields, to get the base (or final) configuration - conf = BaseApplyConfiguration.__response__.parse_obj(data).__root__ + conf = validate_extra_ignore(BaseApplyConfiguration, data).root if not isinstance(conf, BaseVolumeConfiguration): # If it's a final configuration (currently, any configuration other than # BaseVolumeConfiguration), parse again rejecting extra fields # for validation purposes only and return the final configuration - _ = BaseApplyConfiguration.parse_obj(data).__root__ + _ = BaseApplyConfiguration.model_validate(data).root return conf except ValidationError as e: raise ConfigurationError(e) @@ -1475,19 +1501,14 @@ def parse_apply_configuration(data: dict) -> AnyApplyConfiguration: ] -class DstackConfiguration(CoreModel): - __root__: Annotated[ - AnyDstackConfiguration, - Field(discriminator="type"), - ] +def _dstack_configuration_schema(schema: Dict[str, Any]) -> None: + schema["$schema"] = JSON_SCHEMA_DIALECT + # Allow additionalProperties so that vscode and others not supporting + # top-level oneOf do not warn about properties being invalid. + schema["additionalProperties"] = True - class Config(CoreConfig): - json_loads = orjson.loads - json_dumps = pydantic_orjson_dumps_with_indent - @staticmethod - def schema_extra(schema: Dict[str, Any]): - schema["$schema"] = "http://json-schema.org/draft-07/schema#" - # Allow additionalProperties so that vscode and others not supporting - # top-level oneOf do not warn about properties being invalid. - schema["additionalProperties"] = True +class DstackConfiguration( + RootModel[Annotated[AnyDstackConfiguration, Field(discriminator="type")]] +): + model_config = ConfigDict(json_schema_extra=_dstack_configuration_schema) diff --git a/src/dstack/_internal/core/models/duration.py b/src/dstack/_internal/core/models/duration.py new file mode 100644 index 0000000000..e063a60de2 --- /dev/null +++ b/src/dstack/_internal/core/models/duration.py @@ -0,0 +1,130 @@ +import re +from typing import Any, Optional, Union + +from pydantic import BeforeValidator, GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, core_schema +from typing_extensions import Annotated, Literal, overload + + +class Duration(int): + """ + Duration in seconds. + """ + + @classmethod + def parse(cls, v: Union[int, str]) -> "Duration": + if isinstance(v, (int, float)): + return cls(v) + if isinstance(v, str): + try: + return cls(int(v)) + except ValueError: + pass + regex = re.compile(r"(?P\d+) *(?P[smhdw])$") + re_match = regex.match(v) + if not re_match: + raise ValueError(f"Cannot parse the duration {v}") + amount, unit = int(re_match.group("amount")), re_match.group("unit") + multiplier = { + "s": 1, + "m": 60, + "h": 3600, + "d": 24 * 3600, + "w": 7 * 24 * 3600, + }[unit] + return cls(amount * multiplier) + raise ValueError(f"Cannot parse the duration {v}") + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_plain_validator_function( + cls.parse, + serialization=core_schema.plain_serializer_function_ser_schema( + int, return_schema=core_schema.int_schema() + ), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + # A duration is accepted either as a number of seconds or as a shorthand string + # like `2h`, but it always serializes as a number of seconds. + if handler.mode == "validation": + return {"anyOf": [{"type": "integer"}, {"type": "string"}]} + return {"type": "integer"} + + +@overload +def parse_duration(v: None) -> None: ... + + +@overload +def parse_duration(v: Union[int, str]) -> int: ... + + +def parse_duration(v: Optional[Union[int, str]]) -> Optional[int]: + if v is None: + return None + return Duration.parse(v) + + +def parse_off_duration(v: Optional[Union[int, str, bool]]) -> Optional[Union[Literal["off"], int]]: + if v == "off" or v is False: + return "off" + if v is True or v is None: + return None + duration = parse_duration(v) + if duration < 0: + raise ValueError("Duration cannot be negative") + return duration + + +def parse_idle_duration(v: Optional[Union[int, str, bool]]) -> Optional[int]: + # Differs from `parse_off_duration` to accept negative durations as `off` + # for backward compatibility. + if v == "off" or v is False or v == -1: + return -1 + if v is True: + return None + return parse_duration(v) + + +# Both include `None` in their own value domain rather than being wrapped in `Optional[...]` at the +# field, which is why the names say so. `None` means "unspecified, use the default" and `true` is the +# documented way to ask for it, so it belongs to the domain. It is also required mechanically: a +# `BeforeValidator` nested inside an `Optional[...]` runs *after* the nullable check, so the `None` +# it returns for `true` would then be rejected by the inner union. Use plain `Duration` for a +# duration that is required. +# +# The parsed value is `int`, not `Duration`, even though the parse functions return `Duration`, +# because code may assign non-`Duration` values directly. Switching to `Duration` would fail serialization in such cases. + +OptionalOffableDuration = Annotated[ + Optional[Union[Literal["off"], int]], + BeforeValidator(parse_off_duration, json_schema_input_type=Optional[Union[int, str, bool]]), +] +""" +A duration that can be switched off. Value domain: `None` (unspecified), `"off"`, or seconds. + +`false` and `"off"` both normalize to `"off"`; `true` and omission both normalize to `None`. +Negative values are rejected — contrast `OptionalIdleDuration`. +""" + +OptionalIdleDuration = Annotated[ + Optional[int], + BeforeValidator( + parse_idle_duration, + json_schema_input_type=Optional[Union[Literal["off"], int, str, bool]], + ), +] +""" +A duration whose "off" state is the sentinel `-1` rather than a string. + +`false`, `"off"` and `-1` all normalize to `-1`, and any negative value is accepted and left as +is. That is deliberate and load-bearing: `-1` is what older clients and existing stored rows use +to mean "off", so rejecting negatives here would break reads of data already in the database. +""" diff --git a/src/dstack/_internal/core/models/envs.py b/src/dstack/_internal/core/models/envs.py index 5109c01d5d..92e1575a66 100644 --- a/src/dstack/_internal/core/models/envs.py +++ b/src/dstack/_internal/core/models/envs.py @@ -1,7 +1,7 @@ import re from typing import Dict, Iterable, Iterator, List, Mapping, NamedTuple, Tuple, Union, cast -from pydantic import BaseModel, Field, validator +from pydantic import ConfigDict, Field, RootModel, field_validator from typing_extensions import Annotated, Self from dstack._internal.core.models.common import CoreModel @@ -39,25 +39,34 @@ def parse(cls, v: str) -> Self: return cls(key, value) -class Env(BaseModel): +# Accepted either as a list of `VAR=value`/`VAR` strings or as a mapping. `validate_root` below +# normalizes the list form into the mapping form, so the runtime value is always a dict. +_EnvRoot = Union[ + List[Annotated[str, Field(pattern=_ENV_STRING_REGEX)]], + Dict[str, Union[str, EnvSentinel]], +] + + +class Env(RootModel[_EnvRoot]): """ Env represents a mapping of process environment variables, as in environ(7). Environment values may be omitted, in that case the :class:`EnvSentinel` object is used as a placeholder. To create an instance from a `dict[str, str]` or a `list[str]` use pydantic's - :meth:`BaseModel.parse_obj(dict | list)` method. + :meth:`BaseModel.model_validate(dict | list)` method. - NB: this is *NOT* a CoreModel, pydantic-duality, which is used as a base - for the CoreModel, doesn't play well with custom root models. + NB: this is *NOT* a CoreModel. `extra` is meaningless on a root model, but + `coerce_numbers_to_str` is not: without it `env: {PORT: 8080}` stops parsing, since + pydantic v2 does not coerce a YAML number to a str implicitly. """ - __root__: Union[ - List[Annotated[str, Field(regex=_ENV_STRING_REGEX)]], - Dict[str, Union[str, EnvSentinel]], - ] = {} + model_config = ConfigDict(coerce_numbers_to_str=True) - @validator("__root__") + root: _EnvRoot = {} + + @field_validator("root") + @classmethod def validate_root(cls, v: Union[List[str], Dict[str, str]]) -> Dict[str, str]: if isinstance(v, list): d = {} @@ -99,13 +108,13 @@ def __getitem__(self, item): def __setitem__(self, item, value): self._dict[item] = value - def copy(self, **kwargs) -> Self: - # Env.copy() is tricky because it copies only the hidden top-level {"__root__": {...}} + def model_copy(self, **kwargs) -> Self: + # Env.model_copy() is tricky because it copies only the hidden top-level {"root": {...}} # structure, not the actual nested dict representing the env itself. - # So we copy __root__ explicitly in case of a shallow copy. - new_copy = super().copy(**kwargs) + # So we copy root explicitly in case of a shallow copy. + new_copy = super().model_copy(**kwargs) if not kwargs.get("deep", False): - new_copy.__root__ = new_copy.__root__.copy() + new_copy.root = new_copy.root.copy() return new_copy def as_dict(self) -> Dict[str, str]: @@ -146,4 +155,4 @@ def items(self) -> Iterable[Tuple[str, Union[str, EnvSentinel]]]: @property def _dict(self) -> Dict[str, Union[str, EnvSentinel]]: # this property is redundant for runtime and used for _proper_ type signature only - return cast(Dict, self.__root__) + return cast(Dict, self.root) diff --git a/src/dstack/_internal/core/models/files.py b/src/dstack/_internal/core/models/files.py index 2c82fd53b5..1db962165a 100644 --- a/src/dstack/_internal/core/models/files.py +++ b/src/dstack/_internal/core/models/files.py @@ -1,8 +1,10 @@ import pathlib import string +from typing import Any from uuid import UUID -from pydantic import Field, validator +from pydantic import Field, GetCoreSchemaHandler, field_validator +from pydantic_core import CoreSchema, core_schema from typing_extensions import Annotated, Self from dstack._internal.core.models.common import CoreModel @@ -33,6 +35,25 @@ class FilePathMapping(CoreModel): ), ] + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls._parse_shorthand, + model_schema, + json_schema_input_schema=core_schema.union_schema( + [model_schema, core_schema.str_schema()] + ), + ) + + @classmethod + def _parse_shorthand(cls, v: Any) -> Any: + if isinstance(v, str): + return cls.parse(v) + return v + @classmethod def parse(cls, v: str) -> Self: local_path: str @@ -54,7 +75,8 @@ def parse(cls, v: str) -> Self: raise ValueError(f"invalid file path mapping: {v}") return cls(local_path=local_path, path=path) - @validator("path") + @field_validator("path") + @classmethod def validate_path(cls, v) -> str: # True for `C:/.*`, False otherwise, including `/abs/unix/path`, `rel\windows\path`, etc. if pathlib.PureWindowsPath(v).is_absolute(): diff --git a/src/dstack/_internal/core/models/fleets.py b/src/dstack/_internal/core/models/fleets.py index ce636ba8de..963edbfd56 100644 --- a/src/dstack/_internal/core/models/fleets.py +++ b/src/dstack/_internal/core/models/fleets.py @@ -4,17 +4,24 @@ from enum import Enum from typing import Any, Dict, List, Optional, Union -from pydantic import Field, root_validator, validator -from typing_extensions import Annotated, Literal +from pydantic import ( + ConfigDict, + Field, + SerializerFunctionWrapHandler, + field_validator, + model_serializer, + model_validator, +) +from typing_extensions import Annotated, Literal, Self from dstack._internal.core.backends.profile_options import AnyBackendProfileOptions from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( ApplyAction, - CoreConfig, CoreModel, - generate_dual_core_model, + drop_merged_profile, ) +from dstack._internal.core.models.duration import OptionalIdleDuration from dstack._internal.core.models.envs import Env from dstack._internal.core.models.instances import Instance, InstanceOfferWithAvailability, SSHKey from dstack._internal.core.models.profiles import ( @@ -22,12 +29,10 @@ ProfileParams, ProfileRetry, SpotPolicy, - parse_idle_duration, validate_backend_options, ) from dstack._internal.core.models.resources import ResourcesSpec from dstack._internal.utils.common import list_enum_values_for_annotation -from dstack._internal.utils.json_schema import add_extra_schema_types from dstack._internal.utils.tags import tags_validator @@ -80,7 +85,7 @@ class SSHHostParams(CoreModel): ssh_key: Optional[SSHKey] = None blocks: Annotated[ - Optional[Union[Literal["auto"], int]], + Optional[Union[Literal["auto"], Annotated[int, Field(ge=1)]]], Field( description=( "The amount of blocks to split the instance into, a number or `auto`." @@ -88,11 +93,11 @@ class SSHHostParams(CoreModel): " The number of GPUs and CPUs must be divisible by the number of blocks." " Defaults to the top-level `blocks` value" ), - ge=1, ), ] = None - @validator("internal_ip") + @field_validator("internal_ip") + @classmethod def validate_internal_ip(cls, value): if value is None: return value @@ -134,7 +139,8 @@ class SSHParams(CoreModel): ), ] = None - @validator("network") + @field_validator("network") + @classmethod def validate_network(cls, value): if value is None: return value @@ -169,16 +175,17 @@ class FleetNodesSpec(CoreModel): ), ] = None - def dict(self, *args, **kwargs) -> Dict: - # super() does not work with pydantic-duality - res = CoreModel.dict(self, *args, **kwargs) + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler) -> Dict[str, Any]: + res = handler(self) # For backward compatibility with old clients # that do not ignore extra fields due to https://github.com/dstackai/dstack/issues/3066 if "target" in res and res["target"] == res["min"]: del res["target"] return res - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def set_min_and_target_defaults(cls, values): min_ = values.get("min") target = values.get("target") @@ -188,24 +195,25 @@ def set_min_and_target_defaults(cls, values): values["target"] = values["min"] return values - @validator("min") + @field_validator("min") + @classmethod def validate_min(cls, v: int) -> int: if v < 0: raise ValueError("min cannot be negative") return v - @root_validator(skip_on_failure=True) - def _post_validate_ranges(cls, values): - min_ = values["min"] - target = values["target"] - max_ = values.get("max") + @model_validator(mode="after") + def _post_validate_ranges(self) -> Self: + min_ = self.min + target = self.target + max_ = self.max if target < min_: raise ValueError("target must not be be less than min") if max_ is not None and max_ < min_: raise ValueError("max must not be less than min") if max_ is not None and max_ < target: raise ValueError("max must not be less than target") - return values + return self class CommonFleetConfigurationProps(CoreModel): @@ -216,7 +224,7 @@ class CommonFleetConfigurationProps(CoreModel): Field(description="The placement of instances: `any` or `cluster`"), ] = None blocks: Annotated[ - Union[Literal["auto"], int], + Union[Literal["auto"], Annotated[int, Field(ge=1)]], Field( description=( "The amount of blocks to split the instance into, a number or `auto`." @@ -224,7 +232,6 @@ class CommonFleetConfigurationProps(CoreModel): " The number of GPUs and CPUs must be divisible by the number of blocks." " Defaults to `1`, i.e. do not split" ), - ge=1, ), ] = 1 @@ -285,7 +292,7 @@ class BackendFleetConfiguraionProps(CoreModel): Field(description="The maximum instance price per hour, in dollars", gt=0.0), ] = None idle_duration: Annotated[ - Optional[int], + OptionalIdleDuration, Field( description=( "Time to wait before terminating idle instances." @@ -310,7 +317,10 @@ class BackendFleetConfiguraionProps(CoreModel): Field(description="Backend-specific options, applied only to offers from that backend"), ] = None - @validator("nodes", pre=True) + @field_validator( + "nodes", mode="before", json_schema_input_type=Optional[Union[FleetNodesSpec, int, str]] + ) + @classmethod def parse_nodes(cls, v: Optional[Union[dict, str]]) -> Optional[dict]: if isinstance(v, str) and ".." in v: v = v.replace(" ", "") @@ -320,26 +330,8 @@ def parse_nodes(cls, v: Optional[Union[dict, str]]) -> Optional[dict]: return dict(min=v, max=v) return v - _validate_idle_duration = validator("idle_duration", pre=True, allow_reuse=True)( - parse_idle_duration - ) - _validate_tags = validator("tags", pre=True, allow_reuse=True)(tags_validator) - _validate_backend_options = validator("backend_options", allow_reuse=True)( - validate_backend_options - ) - - -class BackendFleetConfigurationPropsConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["nodes"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["idle_duration"], - extra_types=[{"type": "string"}], - ) + _validate_tags = field_validator("tags", mode="before")(tags_validator) + _validate_backend_options = field_validator("backend_options")(validate_backend_options) class SSHFleetConfigurationProps(CoreModel): @@ -353,17 +345,10 @@ class SSHFleetConfigurationProps(CoreModel): ] = Env() -class FleetConfigurationConfig(BackendFleetConfigurationPropsConfig): - @staticmethod - def schema_extra(schema: dict[str, Any]): - BackendFleetConfigurationPropsConfig.schema_extra(schema) - - class FleetConfiguration( SSHFleetConfigurationProps, BackendFleetConfiguraionProps, CommonFleetConfigurationProps, - generate_dual_core_model(FleetConfigurationConfig), ): pass @@ -371,7 +356,6 @@ class FleetConfiguration( class BackendFleetConfiguration( BackendFleetConfiguraionProps, CommonFleetConfigurationProps, - generate_dual_core_model(BackendFleetConfigurationPropsConfig), ): """For the documentation only""" @@ -383,14 +367,9 @@ class SSHFleetConfiguration( """For the documentation only""" -class FleetSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - prop = schema.get("properties", {}) - prop.pop("merged_profile", None) - +class FleetSpec(CoreModel): + model_config = ConfigDict(json_schema_extra=drop_merged_profile) -class FleetSpec(generate_dual_core_model(FleetSpecConfig)): configuration: FleetConfiguration configuration_path: Optional[str] = None profile: Profile @@ -399,20 +378,23 @@ class FleetSpec(generate_dual_core_model(FleetSpecConfig)): autocreated: bool = False """Deprecated. Kept for deserialization of old client requests and existing DB records. """ - # TODO: make `merged_profile` a computed field after migrating to Pydantic v2. + # TODO: consider a `property` or `cached_property` instead of an excluded field. merged_profile: Annotated[Profile, Field(exclude=True)] = None """`merged_profile` stores profile parameters merged from `profile` and `configuration`. Read profile parameters from `merged_profile` instead of `profile` directly. """ - @root_validator + @model_validator(mode="before") + @classmethod def _merged_profile(cls, values) -> Dict: try: - merged_profile = Profile.parse_obj(values["profile"]) - conf = FleetConfiguration.parse_obj(values["configuration"]) + # 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") - for key in ProfileParams.__fields__: + for key in ProfileParams.model_fields: conf_val = getattr(conf, key, None) if conf_val is not None: setattr(merged_profile, key, conf_val) diff --git a/src/dstack/_internal/core/models/gateways.py b/src/dstack/_internal/core/models/gateways.py index b4d2598c18..89da495743 100644 --- a/src/dstack/_internal/core/models/gateways.py +++ b/src/dstack/_internal/core/models/gateways.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Dict, Optional, Union -from pydantic import Field, validator +from pydantic import Field, RootModel, field_validator from typing_extensions import Annotated, Literal from dstack._internal.core.models.backends.base import BackendType @@ -49,11 +49,8 @@ class ACMGatewayCertificate(CoreModel): AnyGatewayCertificate = Union[LetsEncryptGatewayCertificate, ACMGatewayCertificate] -class GatewayCertificate(CoreModel): - __root__: Annotated[ - AnyGatewayCertificate, - Field(discriminator="type"), - ] +class GatewayCertificate(RootModel[Annotated[AnyGatewayCertificate, Field(discriminator="type")]]): + pass class GatewayConfiguration(CoreModel): @@ -120,7 +117,7 @@ class GatewayConfiguration(CoreModel): ), ] = None - _validate_tags = validator("tags", pre=True, allow_reuse=True)(tags_validator) + _validate_tags = field_validator("tags", mode="before")(tags_validator) class GatewaySpec(CoreModel): diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py index cb7656f15c..38fc803108 100644 --- a/src/dstack/_internal/core/models/instances.py +++ b/src/dstack/_internal/core/models/instances.py @@ -4,7 +4,7 @@ from uuid import UUID import gpuhunt -from pydantic import Field, root_validator +from pydantic import Field, model_validator from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( @@ -28,7 +28,8 @@ class Gpu(CoreModel): `assert gpu.vendor is not None` should be a safe type narrowing. """ - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def validate_name_and_vendor(cls, values): is_tpu = False name = values.get("name") @@ -204,7 +205,7 @@ def with_availability(self, **kwargs) -> "InstanceOfferWithAvailability": """Convert to InstanceOfferWithAvailability without re-serializing/re-validating fields. The result shares nested objects with self. This is generally safe because callers discard the original InstanceOffer after conversion.""" - return InstanceOfferWithAvailability.construct(**self.__dict__, **kwargs) + return InstanceOfferWithAvailability.model_construct(**self.__dict__, **kwargs) class InstanceOfferWithAvailability(InstanceOffer): diff --git a/src/dstack/_internal/core/models/profiles.py b/src/dstack/_internal/core/models/profiles.py index 7a448486df..5e64ea2f0e 100644 --- a/src/dstack/_internal/core/models/profiles.py +++ b/src/dstack/_internal/core/models/profiles.py @@ -1,26 +1,33 @@ from enum import Enum -from typing import Any, Dict, List, Optional, Union, overload - -import orjson -from pydantic import Field, root_validator, validator -from typing_extensions import Annotated, Literal +from typing import Any, Dict, List, Optional, Union + +from pydantic import ( + AfterValidator, + BeforeValidator, + ConfigDict, + Field, + field_validator, + model_validator, +) +from typing_extensions import Annotated, Self from dstack._internal.core.backends.profile_options import AnyBackendProfileOptions from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( - CoreConfig, + JSON_SCHEMA_DIALECT, CoreModel, - Duration, EntityReference, - generate_dual_core_model, +) +from dstack._internal.core.models.duration import ( + Duration, + OptionalIdleDuration, + OptionalOffableDuration, ) from dstack._internal.utils.common import list_enum_values_for_annotation from dstack._internal.utils.cron import validate_cron -from dstack._internal.utils.json_schema import add_extra_schema_types -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent from dstack._internal.utils.tags import tags_validator -DEFAULT_RETRY_DURATION = 3600 +DEFAULT_RETRY_DURATION = Duration(3600) DEFAULT_RUN_TERMINATION_IDLE_TIME = 5 * 60 # 5 minutes DEFAULT_FLEET_TERMINATION_IDLE_TIME = 72 * 60 * 60 # 3 days @@ -55,51 +62,6 @@ class StopCriteria(str, Enum): MASTER_DONE = "master-done" -@overload -def parse_duration(v: None) -> None: ... - - -@overload -def parse_duration(v: Union[int, str]) -> int: ... - - -def parse_duration(v: Optional[Union[int, str]]) -> Optional[int]: - if v is None: - return None - return Duration.parse(v) - - -def parse_max_duration(v: Optional[Union[int, str, bool]]) -> Optional[Union[Literal["off"], int]]: - return parse_off_duration(v) - - -def parse_stop_duration( - v: Optional[Union[int, str, bool]], -) -> Optional[Union[Literal["off"], int]]: - return parse_off_duration(v) - - -def parse_off_duration(v: Optional[Union[int, str, bool]]) -> Optional[Union[Literal["off"], int]]: - if v == "off" or v is False: - return "off" - if v is True or v is None: - return None - duration = parse_duration(v) - if duration < 0: - raise ValueError("Duration cannot be negative") - return duration - - -def parse_idle_duration(v: Optional[Union[int, str, bool]]) -> Optional[int]: - # Differs from `parse_off_duration` to accept negative durations as `off` - # for backward compatibility. - if v == "off" or v is False or v == -1: - return -1 - if v is True: - return None - return parse_duration(v) - - def validate_backend_options( v: Optional[List["AnyBackendProfileOptions"]], ) -> Optional[List["AnyBackendProfileOptions"]]: @@ -119,16 +81,7 @@ class RetryEvent(str, Enum): ERROR = "error" -class ProfileRetryConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["duration"], - extra_types=[{"type": "string"}], - ) - - -class ProfileRetry(generate_dual_core_model(ProfileRetryConfig)): +class ProfileRetry(CoreModel): on_events: Annotated[ Optional[List[RetryEvent]], Field( @@ -140,7 +93,7 @@ class ProfileRetry(generate_dual_core_model(ProfileRetryConfig)): ), ] = None duration: Annotated[ - Optional[int], + Optional[Duration], Field( description=( "The maximum period of retrying the run, e.g., `4h` or `1d`." @@ -150,28 +103,18 @@ class ProfileRetry(generate_dual_core_model(ProfileRetryConfig)): ), ] = None - _validate_duration = validator("duration", pre=True, allow_reuse=True)(parse_duration) - - @root_validator - def _validate_fields(cls, values): - on_events = values.get("on_events", None) - if on_events is not None and len(values["on_events"]) == 0: + @model_validator(mode="after") + def _validate_fields(self) -> Self: + on_events = self.on_events + if on_events is not None and len(self.on_events) == 0: raise ValueError("`on_events` cannot be empty") - return values + return self -class UtilizationPolicyConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["time_window"], - extra_types=[{"type": "string"}], - ) +MIN_UTILIZATION_TIME_WINDOW = "5m" -class UtilizationPolicy(generate_dual_core_model(UtilizationPolicyConfig)): - _min_time_window = "5m" - +class UtilizationPolicy(CoreModel): min_gpu_utilization: Annotated[ int, Field( @@ -185,20 +128,20 @@ class UtilizationPolicy(generate_dual_core_model(UtilizationPolicyConfig)): ), ] time_window: Annotated[ - int, + Duration, Field( description=( "The time window of metric samples taking into account to measure utilization" - f" (e.g., `30m`, `1h`). Minimum is `{_min_time_window}`" + f" (e.g., `30m`, `1h`). Minimum is `{MIN_UTILIZATION_TIME_WINDOW}`" ) ), ] - @validator("time_window", pre=True) - def validate_time_window(cls, v: Union[int, str]) -> int: - v = parse_duration(v) - if v < parse_duration(cls._min_time_window): - raise ValueError(f"Minimum time_window is {cls._min_time_window}") + @field_validator("time_window") + @classmethod + def validate_time_window(cls, v: Duration) -> Duration: + if v < Duration.parse(MIN_UTILIZATION_TIME_WINDOW): + raise ValueError(f"Minimum time_window is {MIN_UTILIZATION_TIME_WINDOW}") return v @@ -212,7 +155,8 @@ class Schedule(CoreModel): ), ] - @validator("cron") + @field_validator("cron") + @classmethod def _validate_cron(cls, v: Union[List[str], str]) -> List[str]: if isinstance(v, str): values = [v] @@ -250,16 +194,7 @@ def _parse_fleet_instance_selector_fleet(v: Any) -> Any: return v -class FleetInstanceSelectorConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["fleet"], - extra_types=[{"type": "string", "minLength": 1}], - ) - - -class FleetInstanceSelector(generate_dual_core_model(FleetInstanceSelectorConfig)): +class FleetInstanceSelector(CoreModel): fleet: Annotated[ EntityReference, Field( @@ -272,9 +207,9 @@ class FleetInstanceSelector(generate_dual_core_model(FleetInstanceSelectorConfig ] instance: Annotated[int, Field(description="The fleet instance number", ge=0)] - _validate_fleet = validator("fleet", pre=True, allow_reuse=True)( - _parse_fleet_instance_selector_fleet - ) + _validate_fleet = field_validator( + "fleet", mode="before", json_schema_input_type=Union[EntityReference, str] + )(_parse_fleet_instance_selector_fleet) InstanceSelector = Union[InstanceNameSelector, InstanceHostnameSelector, FleetInstanceSelector] @@ -286,25 +221,17 @@ def parse_instance_selector(v: Union[InstanceSelector, str]) -> InstanceSelector return v -class ProfileParamsConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["max_duration"], - extra_types=[{"type": "boolean"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["stop_duration"], - extra_types=[{"type": "boolean"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["idle_duration"], - extra_types=[{"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["instances"]["items"], - extra_types=[{"type": "string", "minLength": 1}], - ) +FleetReferenceOrShorthand = Annotated[ + Union[ + EntityReference, + str, # For server response compatibility with pre-0.20.14 clients + ], + AfterValidator(EntityReference.parse), +] +InstanceSelectorOrShorthand = Annotated[ + InstanceSelector, + BeforeValidator(parse_instance_selector, json_schema_input_type=Union[InstanceSelector, str]), +] class ProfileParams(CoreModel): @@ -354,7 +281,7 @@ class ProfileParams(CoreModel): Field(description="The policy for resubmitting the run. Defaults to `false`"), ] = None max_duration: Annotated[ - Optional[Union[Literal["off"], int]], + OptionalOffableDuration, Field( description=( "The maximum duration of a run (e.g., `2h`, `1d`, etc)" @@ -365,7 +292,7 @@ class ProfileParams(CoreModel): ), ] = None stop_duration: Annotated[ - Optional[Union[Literal["off"], int]], + OptionalOffableDuration, Field( description=( "The maximum duration of a run graceful stopping." @@ -390,7 +317,7 @@ class ProfileParams(CoreModel): ), ] = None idle_duration: Annotated[ - Optional[int], + OptionalIdleDuration, Field( description=( "Time to wait before terminating idle instances." @@ -431,14 +358,7 @@ class ProfileParams(CoreModel): Field(description=("The schedule for starting the run at specified time")), ] = None fleets: Annotated[ - Optional[ - list[ - Union[ - EntityReference, - str, # For server response compatibility with pre-0.20.14 clients - ] - ] - ], + Optional[list[FleetReferenceOrShorthand]], Field( description=( "The fleets considered for reuse." @@ -448,7 +368,7 @@ class ProfileParams(CoreModel): ), ] = None instances: Annotated[ - Optional[List[InstanceSelector]], + Optional[List[InstanceSelectorOrShorthand]], Field( description=( "The specific fleet instances to consider for reuse." @@ -456,7 +376,7 @@ class ProfileParams(CoreModel): " `name`, `hostname`, or `fleet` and `instance`." " When set, the run is only placed on matching existing instances." ), - min_items=1, + min_length=1, ), ] = None tags: Annotated[ @@ -474,23 +394,8 @@ class ProfileParams(CoreModel): Field(description="Backend-specific options, applied only to offers from that backend"), ] = None - _validate_max_duration = validator("max_duration", pre=True, allow_reuse=True)( - parse_max_duration - ) - _validate_stop_duration = validator("stop_duration", pre=True, allow_reuse=True)( - parse_stop_duration - ) - _validate_idle_duration = validator("idle_duration", pre=True, allow_reuse=True)( - parse_idle_duration - ) - _validate_fleets = validator("fleets", allow_reuse=True, each_item=True)(EntityReference.parse) - _validate_instances = validator("instances", pre=True, allow_reuse=True, each_item=True)( - parse_instance_selector - ) - _validate_tags = validator("tags", pre=True, allow_reuse=True)(tags_validator) - _validate_backend_options = validator("backend_options", allow_reuse=True)( - validate_backend_options - ) + _validate_tags = field_validator("tags", mode="before")(tags_validator) + _validate_backend_options = field_validator("backend_options")(validate_backend_options) class ProfileProps(CoreModel): @@ -505,27 +410,16 @@ class ProfileProps(CoreModel): ] = False -class ProfileConfig(ProfileParamsConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - ProfileParamsConfig.schema_extra(schema) - - class Profile( ProfileProps, ProfileParams, - generate_dual_core_model(ProfileConfig), ): pass -class ProfilesConfigConfig(CoreConfig): - json_loads = orjson.loads - json_dumps = pydantic_orjson_dumps_with_indent - schema_extra = {"$schema": "http://json-schema.org/draft-07/schema#"} - +class ProfilesConfig(CoreModel): + model_config = ConfigDict(json_schema_extra={"$schema": JSON_SCHEMA_DIALECT}) -class ProfilesConfig(generate_dual_core_model(ProfilesConfigConfig)): profiles: List[Profile] def default(self) -> Optional[Profile]: diff --git a/src/dstack/_internal/core/models/repos/remote.py b/src/dstack/_internal/core/models/repos/remote.py index f613e18221..917969e7c5 100644 --- a/src/dstack/_internal/core/models/repos/remote.py +++ b/src/dstack/_internal/core/models/repos/remote.py @@ -3,11 +3,11 @@ import subprocess import time from dataclasses import dataclass -from typing import Annotated, Any, BinaryIO, Callable, Dict, Optional, Union, cast +from typing import Annotated, BinaryIO, Callable, Dict, Optional, Union, cast import git import pydantic -from pydantic import Field +from pydantic import Field, TypeAdapter from typing_extensions import Literal from dstack._internal.core.deprecated import Deprecated @@ -17,7 +17,7 @@ RepoGitError, RepoInvalidGitRepositoryError, ) -from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.repos.base import BaseRepoInfo, Repo from dstack._internal.utils.hash import get_sha256, slugify from dstack._internal.utils.logging import get_logger @@ -29,27 +29,14 @@ SCP_LOCATION_REGEX = re.compile(r"(?P[^/]+)@(?P[^/]+?):(?P.+)", re.IGNORECASE) -class RemoteRepoCredsConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - pass - - -class RemoteRepoCreds(generate_dual_core_model(RemoteRepoCredsConfig)): +class RemoteRepoCreds(CoreModel): clone_url: str private_key: Optional[str] = None oauth_token: Optional[str] = None -class RemoteRepoInfoConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - pass - - class RemoteRepoInfo( BaseRepoInfo, - generate_dual_core_model(RemoteRepoInfoConfig), ): repo_type: Literal["remote"] = "remote" repo_name: str @@ -266,6 +253,22 @@ def get(self) -> bytes: return self.buffer.getvalue() +HTTPS_DEFAULT_PORT = 443 + + +def _explicit_port(port: Optional[int], default: int) -> Optional[int]: + """ + The port only if it was written explicitly. + + pydantic v2's URL types fill in the scheme's default port, where v1 left `port` as `None` + unless the URL spelled it out. Without this, every https repo URL would be rebuilt as + `https://github.com:443/...`. + """ + if port is None or port == default: + return None + return port + + @dataclass class GitRepoURL: """ @@ -287,7 +290,7 @@ def parse( get_ssh_config: Callable[[str], Dict[str, str]] = lambda host: {}, ) -> "GitRepoURL": try: - url = pydantic.parse_obj_as(pydantic.AnyUrl, value) + url = TypeAdapter(pydantic.AnyUrl).validate_python(value) except pydantic.ValidationError: url = scp_location_to_ssh_url(value) @@ -300,7 +303,7 @@ def parse( return GitRepoURL( ssh_user=ssh_config.get("user"), host=url.host.lower(), - https_port=url.port, + https_port=_explicit_port(url.port, default=HTTPS_DEFAULT_PORT), ssh_port=ssh_config.get("port"), path=url.path or "/", original_host=url.host.lower(), @@ -308,7 +311,7 @@ def parse( if url.scheme.lower() == "ssh": return GitRepoURL( - ssh_user=url.user or ssh_config.get("user"), + ssh_user=url.username or ssh_config.get("user"), host=ssh_config.get("hostname", "").lower() or url.host.lower(), https_port=None, ssh_port=url.port or ssh_config.get("port"), @@ -349,7 +352,7 @@ def scp_location_to_ssh_url(scp_location: str) -> Optional[pydantic.AnyHttpUrl]: return None user, host, path = match.group("user"), match.group("host"), match.group("path") try: - return pydantic.parse_obj_as(pydantic.AnyUrl, f"ssh://{user}@{host}/{path}") + return TypeAdapter(pydantic.AnyUrl).validate_python(f"ssh://{user}@{host}/{path}") except pydantic.ValidationError: return None diff --git a/src/dstack/_internal/core/models/resources.py b/src/dstack/_internal/core/models/resources.py index 72febf8eb9..92f7ac4c63 100644 --- a/src/dstack/_internal/core/models/resources.py +++ b/src/dstack/_internal/core/models/resources.py @@ -3,13 +3,24 @@ from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar, Union import gpuhunt -from pydantic import Field, parse_obj_as, root_validator, validator -from pydantic.generics import GenericModel +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetCoreSchemaHandler, + GetJsonSchemaHandler, + SerializerFunctionWrapHandler, + Tag, + field_validator, + model_serializer, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, core_schema from typing_extensions import Annotated -from dstack._internal.core.models.common import CoreConfig, CoreModel, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.utils.common import pretty_resources -from dstack._internal.utils.json_schema import add_extra_schema_types from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -17,18 +28,29 @@ T = TypeVar("T", bound=Union[int, float]) +# The shorthand forms these types accept in addition to their declared shape. Declared on the +# type so that every field using it reports the same thing in the generated JSON Schema, instead +# of each field restating it in a sibling config class. +_INT_OR_STR_INPUT = [core_schema.int_schema(), core_schema.str_schema()] + + +class Range(BaseModel, Generic[T]): + model_config = ConfigDict(extra="forbid") -class Range(GenericModel, Generic[T]): min: Optional[T] = None max: Optional[T] = None - class Config: - extra = "forbid" - @classmethod - def __get_validators__(cls): - yield cls._parse - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls._parse, + model_schema, + # A range is also written as `8`, `2..8`, or `16GB..`. + json_schema_input_schema=core_schema.union_schema([model_schema, *_INT_OR_STR_INPUT]), + ) @classmethod def _parse(cls, v: Any) -> Any: @@ -40,16 +62,13 @@ def _parse(cls, v: Any) -> Any: return dict(min=v, max=v) return v - @root_validator() - def _post_validate(cls, values): - min = values.get("min") - max = values.get("max") - - if min is None and max is None: + @model_validator(mode="after") + def _post_validate(self) -> "Range[T]": + if self.min is None and self.max is None: raise ValueError("Invalid empty range: ..") - if min is not None and max is not None and min > max: - raise ValueError(f"Invalid range order: {min}..{max}") - return values + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError(f"Invalid range order: {self.min}..{self.max}") + return self def __str__(self) -> str: min = self.min if self.min is not None else "" @@ -80,10 +99,6 @@ class Memory(float): Memory size in gigabytes as a float number. Supported units: MB, GB, TB. """ - @classmethod - def __get_validators__(cls): - yield cls.parse - @classmethod def parse(cls, v: Any) -> "Memory": if isinstance(v, (float, int)): @@ -102,12 +117,29 @@ def parse(cls, v: Any) -> "Memory": def __repr__(self): return f"{self:g}GB" + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_plain_validator_function( + cls.parse, + serialization=core_schema.plain_serializer_function_ser_schema( + float, return_schema=core_schema.float_schema() + ), + ) -class ComputeCapability(Tuple[int, int]): @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_json_schema__( + cls, schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + # A memory size is accepted as a number of gigabytes or as `16GB`/`512MB`/`1TB`, + # and always serializes as a number of gigabytes. + if handler.mode == "validation": + return {"anyOf": [{"type": "number"}, {"type": "integer"}, {"type": "string"}]} + return {"type": "number"} + +class ComputeCapability(Tuple[int, int]): @classmethod def validate(cls, v: Any) -> Tuple[int, int]: if isinstance(v, float): @@ -123,22 +155,34 @@ def validate(cls, v: Any) -> Tuple[int, int]: def __str__(self): return f"{self[0]}.{self[1]}" + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_plain_validator_function( + cls.validate, + serialization=core_schema.plain_serializer_function_ser_schema( + list, return_schema=core_schema.list_schema(core_schema.int_schema()) + ), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + serialized = {"type": "array", "items": {"type": "integer"}} + if handler.mode == "validation": + # Written as `7.5` (a YAML float), as `"7.5"`, or as a two-element sequence. + return {"anyOf": [{"type": "number"}, {"type": "string"}, serialized]} + return serialized + DEFAULT_CPU_COUNT = Range[int](min=2) DEFAULT_MEMORY_SIZE = Range[Memory](min=Memory.parse("8GB")) DEFAULT_GPU_COUNT = Range[int](min=1) -class CPUSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["count"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - - -class CPUSpec(generate_dual_core_model(CPUSpecConfig)): +class CPUSpec(CoreModel): arch: Annotated[ Optional[gpuhunt.CPUArchitecture], Field(description="The CPU architecture, one of: `x86`, `arm`"), @@ -146,9 +190,16 @@ class CPUSpec(generate_dual_core_model(CPUSpecConfig)): count: Annotated[Range[int], Field(description="The number of CPU cores")] = DEFAULT_CPU_COUNT @classmethod - def __get_validators__(cls): - yield cls.parse - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls.parse, + model_schema, + # Also written as a count (`8`) or a `:` shorthand (`arm:8`). + json_schema_input_schema=core_schema.union_schema([model_schema, *_INT_OR_STR_INPUT]), + ) @classmethod def parse(cls, v: Any) -> Any: @@ -176,11 +227,17 @@ def parse(cls, v: Any) -> Any: # Range and min/max dict - for backward compatibility if isinstance(v, Range): return {"arch": None, "count": v} - if isinstance(v, Mapping) and v.keys() == {"min", "max"}: + # A subset rather than exactly {"min", "max"}: `ResourcesSpec` serializes `cpu` down to its + # count for old clients, and under `exclude_none=True` that leaves just `{"min": ...}`. + # Requiring both keys made the round trip land on the `Range[int]` arm of `ResourcesSpec.cpu` + # instead of coming back as a `CPUSpec`. `arch`/`count` are the only `CPUSpec` fields, so a + # mapping of min/max is unambiguously a range. + if isinstance(v, Mapping) and v and v.keys() <= {"min", "max"}: return {"arch": None, "count": v} return v - @validator("arch", pre=True) + @field_validator("arch", mode="before") + @classmethod def _validate_arch(cls, v: Any) -> Any: if v is None: return None @@ -191,28 +248,7 @@ def _validate_arch(cls, v: Any) -> Any: return v -class GPUSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["count"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["name"], - extra_types=[{"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["memory"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["total_memory"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - - -class GPUSpec(generate_dual_core_model(GPUSpecConfig)): +class GPUSpec(CoreModel): vendor: Annotated[ Optional[gpuhunt.AcceleratorVendor], Field( @@ -241,9 +277,16 @@ class GPUSpec(generate_dual_core_model(GPUSpecConfig)): ] = None @classmethod - def __get_validators__(cls): - yield cls.parse - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls.parse, + model_schema, + # Also written as a count (`8`) or a `:`-separated shorthand (`A100:8`, `nvidia:16GB`). + json_schema_input_schema=core_schema.union_schema([model_schema, *_INT_OR_STR_INPUT]), + ) @classmethod def parse(cls, v: Any) -> Any: @@ -280,7 +323,8 @@ def parse(cls, v: Any) -> Any: return spec return v - @validator("name", pre=True) + @field_validator("name", mode="before", json_schema_input_type=Optional[Union[List[str], str]]) + @classmethod def _validate_name(cls, v: Any) -> Any: if v is None: return None @@ -297,7 +341,8 @@ def _validate_name(cls, v: Any) -> Any: logger.warning("`tpu-` prefix is deprecated, specify gpu_vendor instead") return validated - @validator("vendor", pre=True) + @field_validator("vendor", mode="before") + @classmethod def _validate_vendor( cls, v: Union[str, gpuhunt.AcceleratorVendor, None] ) -> Optional[gpuhunt.AcceleratorVendor]: @@ -307,7 +352,9 @@ def _validate_vendor( return v if isinstance(v, str): return cls._vendor_from_string(v) - raise TypeError(f"Unsupported type: {v!r}") + # A TypeError raised inside a validator is no longer converted to a ValidationError + # in pydantic v2, so it would escape as a 500 instead of a 422. + raise ValueError(f"Unsupported type: {v!r}") @classmethod def _vendor_from_string(cls, v: str) -> gpuhunt.AcceleratorVendor: @@ -322,22 +369,20 @@ def _vendor_from_string(cls, v: str) -> gpuhunt.AcceleratorVendor: DEFAULT_GPU_SPEC = GPUSpec(count=Range[int](min=0, max=None)) -class DiskSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["size"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - - -class DiskSpec(generate_dual_core_model(DiskSpecConfig)): +class DiskSpec(CoreModel): size: Annotated[Range[Memory], Field(description="Disk size")] @classmethod - def __get_validators__(cls): - yield cls._parse - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + model_schema = handler(source_type) + return core_schema.no_info_before_validator_function( + cls._parse, + model_schema, + # Also written as a bare size (`100GB`). + json_schema_input_schema=core_schema.union_schema([model_schema, *_INT_OR_STR_INPUT]), + ) @classmethod def _parse(cls, v: Any) -> Any: @@ -349,36 +394,21 @@ def _parse(cls, v: Any) -> Any: DEFAULT_DISK = DiskSpec(size=Range[Memory](min=Memory.parse("100GB"), max=None)) -class ResourcesSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - add_extra_schema_types( - schema["properties"]["cpu"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["memory"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["shm_size"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["gpu"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - add_extra_schema_types( - schema["properties"]["disk"], - extra_types=[{"type": "integer"}, {"type": "string"}], - ) - - -class ResourcesSpec(generate_dual_core_model(ResourcesSpecConfig)): +class ResourcesSpec(CoreModel): # TODO: remove `Range[int]` in 0.20. It is kept only for backward compatibility. - cpu: Annotated[Union[CPUSpec, Range[int]], Field(description="The CPU requirements")] = ( - CPUSpec() - ) + cpu: Annotated[ + Union[ + # `Tag` only names the arm in validation errors. Without it the `loc` of a bad `cpu` + # spells out the whole wrapped schema — + # `cpu.function-before[parse(), function-before[parse(), ... CPUSpec]].count` — which + # is what `dstack apply` shows the user. + Annotated[CPUSpec, Tag("CPUSpec")], + Annotated[Range[int], Tag("Range[int]")], + ], + # `CPUSpec` and `Range[int]` both accept a bare int/str, so the arm has to be picked by + # declaration order rather than by pydantic v2's "smart" union resolution. + Field(description="The CPU requirements", union_mode="left_to_right"), + ] = CPUSpec() memory: Annotated[Range[Memory], Field(description="The RAM size (e.g., `8GB`)")] = ( DEFAULT_MEMORY_SIZE ) @@ -406,7 +436,7 @@ def unconstrained(cls) -> "ResourcesSpec": def pretty_format(self) -> str: # TODO: Remove in 0.20. Use self.cpu directly - cpu = parse_obj_as(CPUSpec, self.cpu) + cpu = CPUSpec.model_validate(self.cpu) resources: Dict[str, Any] = dict(cpu_arch=cpu.arch, cpus=cpu.count, memory=self.memory) if self.gpu: gpu = self.gpu @@ -423,15 +453,15 @@ def pretty_format(self) -> str: res = pretty_resources(**resources) return res - def dict(self, *args, **kwargs) -> Dict: - # super() does not work with pydantic-duality - res = CoreModel.dict(self, *args, **kwargs) + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler) -> Dict[str, Any]: + res = handler(self) self._update_serialized_cpu(res) return res # TODO: Remove in 0.20. Added for backward compatibility. def _update_serialized_cpu(self, values: Dict): - cpu = values["cpu"] + cpu = values.get("cpu") if cpu: arch = cpu.get("arch") count = cpu.get("count") diff --git a/src/dstack/_internal/core/models/runs.py b/src/dstack/_internal/core/models/runs.py index 611ba87118..a292a928b8 100644 --- a/src/dstack/_internal/core/models/runs.py +++ b/src/dstack/_internal/core/models/runs.py @@ -3,18 +3,17 @@ from typing import Any, Dict, List, Literal, Optional from urllib.parse import urlparse -from pydantic import UUID4, Field, root_validator -from typing_extensions import Annotated +from pydantic import UUID4, ConfigDict, Field, model_validator +from typing_extensions import Annotated, Self from dstack._internal.core.backends.profile_options import AnyBackendProfileOptions from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( ApplyAction, - CoreConfig, CoreModel, NetworkMode, RegistryAuth, - generate_dual_core_model, + drop_merged_profile, ) from dstack._internal.core.models.configurations import ( DEFAULT_PROBE_METHOD, @@ -519,14 +518,9 @@ class Job(CoreModel): job_connection_info: Optional[JobConnectionInfo] = None -class RunSpecConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - prop = schema.get("properties", {}) - prop.pop("merged_profile", None) +class RunSpec(CoreModel): + model_config = ConfigDict(json_schema_extra=drop_merged_profile) - -class RunSpec(generate_dual_core_model(RunSpecConfig)): # TODO: consider removing `run_name` here because it is already passed in `configuration`. run_name: Annotated[ Optional[str], @@ -588,23 +582,26 @@ class RunSpec(generate_dual_core_model(RunSpecConfig)): " Can be empty only before the run is submitted." ), ] = None - # TODO: make `merged_profile` a computed field after migrating to Pydantic v2. + # TODO: consider a `property` or `cached_property` instead of an excluded field. merged_profile: Annotated[Profile, Field(exclude=True)] = None """`merged_profile` stores profile parameters merged from `profile` and `configuration`. Read profile parameters from `merged_profile` instead of `profile` directly. """ - @root_validator + @model_validator(mode="before") + @classmethod def _merged_profile(cls, values) -> Dict: if values.get("profile") is None: merged_profile = Profile(name="default") else: - merged_profile = Profile.parse_obj(values["profile"]) + # 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.parse_obj(values["configuration"]).__root__ + conf = RunConfiguration.model_validate(values["configuration"]).root except KeyError: raise ValueError("Missing configuration") - for key in ProfileParams.__fields__: + for key in ProfileParams.model_fields: conf_val = getattr(conf, key, None) if conf_val is not None: setattr(merged_profile, key, conf_val) @@ -613,19 +610,19 @@ def _merged_profile(cls, values) -> Dict: values["merged_profile"] = merged_profile return values - @root_validator - def _validate_dynamo_no_retry(cls, values) -> Dict: + @model_validator(mode="after") + def _validate_dynamo_no_retry(self) -> Self: """Reject `retry` for services with a Dynamo router replica group. Dynamo workers cache the router's internal IP at provisioning time. A retry would produce a new router and likely a new internal_ip, leaving workers bound to a router that no longer exists. """ - merged_profile = values.get("merged_profile") - cfg = values.get("configuration") + merged_profile = self.merged_profile + cfg = self.configuration if merged_profile is None or merged_profile.retry is None: - return values + return self if not isinstance(cfg, ServiceConfiguration): - return values + return self for g in cfg.replica_groups: if g.router is not None and g.router.type == RouterType.DYNAMO: raise ValueError( @@ -636,7 +633,7 @@ def _validate_dynamo_no_retry(cls, values) -> Dict: "Remove `retry` from the profile/configuration and " "re-apply." ) - return values + return self class ServiceModelSpec(CoreModel): @@ -696,7 +693,7 @@ class Run(CoreModel): run_spec: RunSpec jobs: List[Job] latest_job_submission: Optional[JobSubmission] = None - cost: float = 0 + cost: float = 0.0 service: Optional[ServiceSpec] = None deployment_num: int = 0 """`deployment_num` uses a default value for compatibility with pre-0.19.14 servers.""" diff --git a/src/dstack/_internal/core/models/volumes.py b/src/dstack/_internal/core/models/volumes.py index f786804bfc..d651e717a4 100644 --- a/src/dstack/_internal/core/models/volumes.py +++ b/src/dstack/_internal/core/models/volumes.py @@ -4,13 +4,13 @@ from pathlib import PurePosixPath from typing import Any, Dict, List, Literal, Optional, Tuple, Union -from pydantic import Field, ValidationError, validator +from pydantic import Field, RootModel, ValidationError, field_validator from typing_extensions import Annotated, Self from dstack._internal.core.errors import ConfigurationError from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import CoreModel -from dstack._internal.core.models.profiles import parse_idle_duration +from dstack._internal.core.models.duration import OptionalIdleDuration from dstack._internal.core.models.resources import Memory from dstack._internal.utils.common import get_or_error from dstack._internal.utils.tags import tags_validator @@ -47,7 +47,7 @@ class BaseVolumeConfiguration(CoreModel): Field(description="The volume size. Must be specified when creating new volumes"), ] = None auto_cleanup_duration: Annotated[ - Optional[Union[str, int]], + OptionalIdleDuration, Field( description=( "Time to wait after volume is no longer used by any job before deleting it. " @@ -67,10 +67,7 @@ class BaseVolumeConfiguration(CoreModel): ), ] = None - _validate_tags = validator("tags", pre=True, allow_reuse=True)(tags_validator) - _validate_auto_cleanup_duration = validator( - "auto_cleanup_duration", pre=True, allow_reuse=True - )(parse_idle_duration) + _validate_tags = field_validator("tags", mode="before")(tags_validator) @property def external_volume_id(self) -> Optional[str]: @@ -184,13 +181,15 @@ def external_volume_id(self) -> Optional[str]: ] -class VolumeConfiguration(CoreModel): - __root__: Annotated[AnyVolumeConfiguration, Field(discriminator="backend")] +class VolumeConfiguration( + RootModel[Annotated[AnyVolumeConfiguration, Field(discriminator="backend")]] +): + pass def parse_volume_configuration(data: dict) -> AnyVolumeConfiguration: try: - return VolumeConfiguration.parse_obj(data).__root__ + return VolumeConfiguration.model_validate(data).root except ValidationError as e: raise ConfigurationError(e) @@ -323,7 +322,7 @@ class VolumeMountPoint(CoreModel): ] path: Annotated[str, Field(description="The absolute container path to mount the volume at")] - _validate_path = validator("path", allow_reuse=True)(_validate_mount_point_path) + _validate_path = field_validator("path")(_validate_mount_point_path) @classmethod def parse(cls, v: str) -> Self: @@ -344,10 +343,8 @@ class InstanceMountPoint(CoreModel): ), ] = False - _validate_instance_path = validator("instance_path", allow_reuse=True)( - _validate_mount_point_path - ) - _validate_path = validator("path", allow_reuse=True)(_validate_mount_point_path) + _validate_instance_path = field_validator("instance_path")(_validate_mount_point_path) + _validate_path = field_validator("path")(_validate_mount_point_path) @classmethod def parse(cls, v: str) -> Self: diff --git a/src/dstack/_internal/core/services/configs/__init__.py b/src/dstack/_internal/core/services/configs/__init__.py index 8bc72a6394..4c88f6b061 100644 --- a/src/dstack/_internal/core/services/configs/__init__.py +++ b/src/dstack/_internal/core/services/configs/__init__.py @@ -28,13 +28,13 @@ def save(self): self.config_filepath.parent.mkdir(parents=True, exist_ok=True) with self.config_filepath.open("w") as f: # hack to convert enums to strings, etc. - yaml.dump(json.loads(self.config.json()), f) + yaml.dump(json.loads(self.config.model_dump_json()), f) def load(self): try: with open(self.config_filepath, "r") as f: config = yaml.safe_load(f) - self.config = GlobalConfig.parse_obj(config) + self.config = GlobalConfig.model_validate(config) except FileNotFoundError: self.config = GlobalConfig() except ValidationError: diff --git a/src/dstack/_internal/core/services/diff.py b/src/dstack/_internal/core/services/diff.py index 154dee1db8..794d5c9ab9 100644 --- a/src/dstack/_internal/core/services/diff.py +++ b/src/dstack/_internal/core/services/diff.py @@ -33,14 +33,7 @@ def diff_models( A dict of changed fields in the form of `{: {"old": old_value, "new": new_value}}` """ - if not ( - type(old) is type(new) - or ( - isinstance(old, CoreModel) - and isinstance(new, CoreModel) - and type(old).__response__ is type(new).__response__ - ) - ): + if type(old) is not type(new): raise TypeError("Both instances must be of the same Pydantic model class.") if reset is not None: @@ -48,7 +41,7 @@ def diff_models( new = copy_model(new, reset=reset) changes: ModelDiff = {} - for field in old.__fields__: + for field in type(old).model_fields: old_value = getattr(old, field) new_value = getattr(new, field) if old_value != new_value: @@ -64,8 +57,8 @@ def copy_model(model: M, reset: Optional[IncludeExcludeType] = None) -> M: """ Returns a deep copy of the model instance. - Implemented as `BaseModel.parse_obj(BaseModel.dict())`, thus, - unlike `BaseModel.copy(deep=True)`, runs all validations. + Implemented as `model_validate(model_dump())`, thus, + unlike `model_copy(deep=True)`, runs all validations. The fields specified in the `reset` option are reset to their default values. @@ -75,7 +68,7 @@ def copy_model(model: M, reset: Optional[IncludeExcludeType] = None) -> M: Returns: A deep copy of the model instance. """ - return type(model).parse_obj(model.dict(exclude=reset)) + return type(model).model_validate(model.model_dump(exclude=reset)) def flatten_diff_fields(diff: ModelDiff, prefix: str = "") -> list[str]: diff --git a/src/dstack/_internal/core/services/profiles.py b/src/dstack/_internal/core/services/profiles.py index 71ed2e520e..6850f45ba5 100644 --- a/src/dstack/_internal/core/services/profiles.py +++ b/src/dstack/_internal/core/services/profiles.py @@ -20,7 +20,7 @@ def get_retry(profile: Profile) -> Optional[Retry]: duration=DEFAULT_RETRY_DURATION, ) return None - profile_retry = profile_retry.copy() + profile_retry = profile_retry.model_copy() if profile_retry.on_events is None: profile_retry.on_events = [ RetryEvent.NO_CAPACITY, @@ -29,7 +29,7 @@ def get_retry(profile: Profile) -> Optional[Retry]: ] if profile_retry.duration is None: profile_retry.duration = DEFAULT_RETRY_DURATION - return Retry.parse_obj(profile_retry) + return Retry.model_validate(profile_retry.model_dump()) def get_termination( diff --git a/src/dstack/_internal/proxy/gateway/repo/repo.py b/src/dstack/_internal/proxy/gateway/repo/repo.py index 0956faf04c..a3b69d4565 100644 --- a/src/dstack/_internal/proxy/gateway/repo/repo.py +++ b/src/dstack/_internal/proxy/gateway/repo/repo.py @@ -119,11 +119,11 @@ async def writer(self): @staticmethod def load(state_file: Path) -> "GatewayProxyRepo": if state_file.exists(): - state = State.parse_file(state_file) + state = State.model_validate_json(state_file.read_text()) else: state = None return GatewayProxyRepo(state=state, file=state_file) def save(self) -> None: if self._file is not None: - self._file.write_text(self._state.json()) + self._file.write_text(self._state.model_dump_json()) diff --git a/src/dstack/_internal/proxy/gateway/services/model_routers/base.py b/src/dstack/_internal/proxy/gateway/services/model_routers/base.py index 83ec14cb4d..a47677f400 100644 --- a/src/dstack/_internal/proxy/gateway/services/model_routers/base.py +++ b/src/dstack/_internal/proxy/gateway/services/model_routers/base.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import List, Literal, Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from dstack._internal.core.models.routers import AnyServiceRouterConfig @@ -10,8 +10,7 @@ class RouterContext(BaseModel): """Context for router initialization and configuration.""" - class Config: - frozen = True + model_config = ConfigDict(frozen=True) host: str = "127.0.0.1" port: int diff --git a/src/dstack/_internal/proxy/gateway/services/nginx.py b/src/dstack/_internal/proxy/gateway/services/nginx.py index 9bc750b997..ca3700825a 100644 --- a/src/dstack/_internal/proxy/gateway/services/nginx.py +++ b/src/dstack/_internal/proxy/gateway/services/nginx.py @@ -37,7 +37,7 @@ class SiteConfig(BaseModel): def render(self) -> str: template = read_package_resource(f"{self.type}.jinja2") - render_dict = self.dict() + render_dict = self.model_dump() render_dict["proxy_port"] = PROXY_PORT_ON_GATEWAY return jinja2.Template(template).render(**render_dict) diff --git a/src/dstack/_internal/proxy/gateway/services/registry.py b/src/dstack/_internal/proxy/gateway/services/registry.py index f190523a39..3b7968d62b 100644 --- a/src/dstack/_internal/proxy/gateway/services/registry.py +++ b/src/dstack/_internal/proxy/gateway/services/registry.py @@ -443,7 +443,7 @@ async def _migrate_cors_enabled(repo: GatewayProxyRepo) -> None: not service.cors_enabled and (service.project_name, service.run_name) in openai_run_names ): - updated = models.Service(**{**service.dict(), "cors_enabled": True}) + updated = models.Service(**{**service.model_dump(), "cors_enabled": True}) await repo.set_service(updated) diff --git a/src/dstack/_internal/proxy/lib/models.py b/src/dstack/_internal/proxy/lib/models.py index a6128412d9..16e9fd4d6e 100644 --- a/src/dstack/_internal/proxy/lib/models.py +++ b/src/dstack/_internal/proxy/lib/models.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Iterable, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from typing_extensions import Annotated from dstack._internal.core.models.instances import SSHConnectionParams @@ -14,8 +14,7 @@ # Models should be immutable so that they can be stored in memory and safely shared by # coroutines without copying on every read operation. class ImmutableModel(BaseModel): - class Config: - frozen = True + model_config = ConfigDict(frozen=True) class Replica(ImmutableModel): @@ -38,11 +37,11 @@ class IPAddressPartitioningKey(ImmutableModel): class HeaderPartitioningKey(ImmutableModel): type: Literal["header"] = "header" - header: Annotated[str, Field(regex=r"^[a-zA-Z0-9-_]+$")] # prevent Nginx config injection + header: Annotated[str, Field(pattern=r"^[a-zA-Z0-9-_]+$")] # prevent Nginx config injection class RateLimit(ImmutableModel): - prefix: Annotated[str, Field(regex=r"^/[^\s\\{}]*$")] # prevent Nginx config injection + prefix: Annotated[str, Field(pattern=r"^/[^\s\\{}]*$")] # prevent Nginx config injection key: Annotated[ Union[IPAddressPartitioningKey, HeaderPartitioningKey], Field(discriminator="type"), @@ -78,7 +77,7 @@ def https_safe(self) -> bool: return self.https def with_replicas(self, new_replicas: Iterable[Replica]) -> "Service": - return Service(**{**self.dict(), "replicas": tuple(new_replicas)}) + return Service(**{**self.model_dump(), "replicas": tuple(new_replicas)}) def find_replica(self, replica_id: str) -> Optional[Replica]: for replica in self.replicas: diff --git a/src/dstack/_internal/proxy/lib/routers/model_proxy.py b/src/dstack/_internal/proxy/lib/routers/model_proxy.py index e5a5c4cee3..4e1cabab0a 100644 --- a/src/dstack/_internal/proxy/lib/routers/model_proxy.py +++ b/src/dstack/_internal/proxy/lib/routers/model_proxy.py @@ -99,4 +99,4 @@ async def _adaptor(self, first_chunk: Optional[ChatCompletionsChunk]) -> AsyncIt @staticmethod def _encode_chunk(chunk: ChatCompletionsChunk) -> bytes: - return f"data:{chunk.json()}\n\n".encode() + return f"data:{chunk.model_dump_json()}\n\n".encode() diff --git a/src/dstack/_internal/proxy/lib/services/model_proxy/clients/openai.py b/src/dstack/_internal/proxy/lib/services/model_proxy/clients/openai.py index ecd49823fd..dfd0e526f1 100644 --- a/src/dstack/_internal/proxy/lib/services/model_proxy/clients/openai.py +++ b/src/dstack/_internal/proxy/lib/services/model_proxy/clients/openai.py @@ -4,6 +4,7 @@ from fastapi import status from pydantic import ValidationError +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.proxy.lib.errors import ProxyError from dstack._internal.proxy.lib.schemas.model_proxy import ( ChatCompletionsChunk, @@ -21,21 +22,23 @@ def __init__(self, http_client: httpx.AsyncClient, prefix: str): async def generate(self, request: ChatCompletionsRequest) -> ChatCompletionsResponse: try: resp = await self._http.post( - f"{self._prefix}/chat/completions", json=request.dict(exclude_unset=True) + f"{self._prefix}/chat/completions", json=request.model_dump(exclude_unset=True) ) await self._propagate_error(resp) except httpx.RequestError as e: raise ProxyError(f"Error requesting model: {e!r}", status.HTTP_502_BAD_GATEWAY) try: - return ChatCompletionsResponse.__response__.parse_raw(resp.content) + return validate_json_extra_ignore(ChatCompletionsResponse, resp.content) except ValidationError as e: raise ProxyError(f"Invalid response from model: {e}", status.HTTP_502_BAD_GATEWAY) async def stream(self, request: ChatCompletionsRequest) -> AsyncIterator[ChatCompletionsChunk]: try: async with self._http.stream( - "POST", f"{self._prefix}/chat/completions", json=request.dict(exclude_unset=True) + "POST", + f"{self._prefix}/chat/completions", + json=request.model_dump(exclude_unset=True), ) as resp: await self._propagate_error(resp) @@ -52,7 +55,7 @@ async def stream(self, request: ChatCompletionsRequest) -> AsyncIterator[ChatCom @staticmethod def _parse_chunk_data(data: str) -> ChatCompletionsChunk: try: - return ChatCompletionsChunk.__response__.parse_raw(data) + return validate_json_extra_ignore(ChatCompletionsChunk, data) except ValidationError as e: raise ProxyError(f"Invalid chunk in model stream: {e}", status.HTTP_502_BAD_GATEWAY) diff --git a/src/dstack/_internal/proxy/lib/services/model_proxy/clients/tgi.py b/src/dstack/_internal/proxy/lib/services/model_proxy/clients/tgi.py index 70c8683a6c..2b1742acdc 100644 --- a/src/dstack/_internal/proxy/lib/services/model_proxy/clients/tgi.py +++ b/src/dstack/_internal/proxy/lib/services/model_proxy/clients/tgi.py @@ -79,7 +79,7 @@ async def generate(self, request: ChatCompletionsRequest) -> ChatCompletionsResp return ChatCompletionsResponse( id=uuid.uuid4().hex, choices=choices, - created=int(datetime.datetime.utcnow().timestamp()), + created=int(datetime.datetime.now(datetime.timezone.utc).timestamp()), model=request.model, system_fingerprint=f"fp_{data['details']['seed']}", usage=ChatCompletionsUsage( @@ -91,7 +91,7 @@ async def generate(self, request: ChatCompletionsRequest) -> ChatCompletionsResp async def stream(self, request: ChatCompletionsRequest) -> AsyncIterator[ChatCompletionsChunk]: completion_id = uuid.uuid4().hex - created = int(datetime.datetime.utcnow().timestamp()) + created = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) payload = self.get_payload(request) try: diff --git a/src/dstack/_internal/server/app.py b/src/dstack/_internal/server/app.py index bf884d001a..6380961e38 100644 --- a/src/dstack/_internal/server/app.py +++ b/src/dstack/_internal/server/app.py @@ -71,7 +71,7 @@ from dstack._internal.server.utils import otel, sentry_utils from dstack._internal.server.utils.logging import configure_logging from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, CustomStaticFiles, check_client_server_compatibility, error_detail, @@ -270,14 +270,14 @@ async def forbidden_error_handler(request: Request, exc: ForbiddenError): msg = "Access denied" if len(exc.args) > 0: msg = exc.args[0] - return CustomORJSONResponse( + return CustomJSONResponse( status_code=status.HTTP_403_FORBIDDEN, content=error_detail(msg), ) @app.exception_handler(ServerClientError) async def server_client_error_handler(request: Request, exc: ServerClientError): - return CustomORJSONResponse( + return CustomJSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": get_server_client_error_details(exc)}, ) @@ -285,7 +285,7 @@ async def server_client_error_handler(request: Request, exc: ServerClientError): @app.exception_handler(OSError) async def os_error_handler(request, exc: OSError): if exc.errno in [36, 63]: - return CustomORJSONResponse( + return CustomJSONResponse( {"detail": "Filename too long"}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -362,7 +362,7 @@ def _extract_endpoint_label(request: Request, response: Response) -> str: @app.get("/healthcheck") async def healthcheck(): - return CustomORJSONResponse(content={"status": "running"}) + return CustomJSONResponse(content={"status": "running"}) if ui and Path(__file__).parent.joinpath("statics").exists(): app.mount( @@ -376,7 +376,7 @@ async def custom_http_exception_handler(request, exc): or _is_proxy_request(request) or _is_prometheus_request(request) ): - return CustomORJSONResponse( + return CustomJSONResponse( {"detail": exc.detail}, status_code=status.HTTP_404_NOT_FOUND, ) diff --git a/src/dstack/_internal/server/background/pipeline_tasks/instances/check.py b/src/dstack/_internal/server/background/pipeline_tasks/instances/check.py index 779d83435a..9be54939c9 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/instances/check.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/instances/check.py @@ -171,7 +171,7 @@ async def check_instance(instance_model: InstanceModel) -> ProcessResult: instance_id=instance_model.id, collected_at=get_current_datetime(), status=health_status, - response=instance_check.health_response.json(), + response=instance_check.health_response.model_dump_json(), ) set_health_update( @@ -357,7 +357,9 @@ async def _process_wait_for_instance_provisioning_data( instance_model.project.ssh_public_key, instance_model.project.ssh_private_key, ) - result.instance_update_map["job_provisioning_data"] = job_provisioning_data.json() + result.instance_update_map["job_provisioning_data"] = ( + job_provisioning_data.model_dump_json() + ) except ProvisioningError as exc: logger.warning( "Error while waiting for instance %s to become running: %s", diff --git a/src/dstack/_internal/server/background/pipeline_tasks/instances/cloud_provisioning.py b/src/dstack/_internal/server/background/pipeline_tasks/instances/cloud_provisioning.py index e86862cd4e..cb87d0294a 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/instances/cloud_provisioning.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/instances/cloud_provisioning.py @@ -213,9 +213,13 @@ async def create_cloud_instance(instance_model: InstanceModel) -> ProcessResult: result.instance_update_map["backend"] = backend.TYPE result.instance_update_map["region"] = instance_offer.region result.instance_update_map["price"] = instance_offer.price - result.instance_update_map["instance_configuration"] = instance_configuration.json() - result.instance_update_map["job_provisioning_data"] = job_provisioning_data.json() - result.instance_update_map["offer"] = instance_offer.json() + result.instance_update_map["instance_configuration"] = ( + instance_configuration.model_dump_json() + ) + result.instance_update_map["job_provisioning_data"] = ( + job_provisioning_data.model_dump_json() + ) + result.instance_update_map["offer"] = instance_offer.model_dump_json() result.instance_update_map["total_blocks"] = instance_offer.total_blocks result.instance_update_map["started_at"] = NOW_PLACEHOLDER @@ -398,7 +402,7 @@ async def _find_or_create_suitable_placement_group_model( backend=instance_offer.backend, region=instance_offer.region, placement_strategy=PlacementStrategy.CLUSTER, - ).json(), + ).model_dump_json(), ) placement_group = placement_group_model_to_placement_group(placement_group_model) logger.debug( @@ -438,5 +442,5 @@ async def _find_or_create_suitable_placement_group_model( return None, False placement_group.provisioning_data = provisioning_data - placement_group_model.provisioning_data = provisioning_data.json() + placement_group_model.provisioning_data = provisioning_data.model_dump_json() return placement_group_model, True diff --git a/src/dstack/_internal/server/background/pipeline_tasks/instances/common.py b/src/dstack/_internal/server/background/pipeline_tasks/instances/common.py index 7ae5ad852a..ef12cebf98 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/instances/common.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/instances/common.py @@ -193,8 +193,8 @@ def set_gpu_driver_update( if gpu_driver is None: return False current = job_provisioning_data.gpu_driver - if current is not None and current.dict() == gpu_driver.dict(): + if current == gpu_driver: return False job_provisioning_data.gpu_driver = gpu_driver - update_map["job_provisioning_data"] = job_provisioning_data.json() + update_map["job_provisioning_data"] = job_provisioning_data.model_dump_json() return True diff --git a/src/dstack/_internal/server/background/pipeline_tasks/instances/ssh_deploy.py b/src/dstack/_internal/server/background/pipeline_tasks/instances/ssh_deploy.py index b4e3e1122a..c2c711cf24 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/instances/ssh_deploy.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/instances/ssh_deploy.py @@ -17,6 +17,7 @@ ) from dstack._internal.core.errors import SSHProvisioningError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.instances import ( InstanceAvailability, InstanceOfferWithAvailability, @@ -52,7 +53,7 @@ run_shim_as_systemd_service, upload_envs, ) -from dstack._internal.utils.common import get_current_datetime, run_async +from dstack._internal.utils.common import get_current_datetime, get_or_error, run_async from dstack._internal.utils.logging import get_logger from dstack._internal.utils.network import get_ip_from_network, is_ip_among_addresses @@ -194,8 +195,8 @@ async def add_ssh_instance(instance_model: InstanceModel) -> ProcessResult: ) result.instance_update_map["backend"] = BackendType.REMOTE result.instance_update_map["price"] = 0 - result.instance_update_map["offer"] = instance_offer.json() - result.instance_update_map["job_provisioning_data"] = job_provisioning_data.json() + result.instance_update_map["offer"] = instance_offer.model_dump_json() + result.instance_update_map["job_provisioning_data"] = job_provisioning_data.model_dump_json() result.instance_update_map["started_at"] = NOW_PLACEHOLDER result.instance_update_map["total_blocks"] = blocks return result @@ -212,8 +213,8 @@ def _resolve_ssh_instance_network( instance_network = None internal_ip = None try: - default_job_provisioning_data = JobProvisioningData.__response__.parse_raw( - instance_model.job_provisioning_data + default_job_provisioning_data = validate_json_extra_ignore( + JobProvisioningData, get_or_error(instance_model.job_provisioning_data) ) instance_network = default_job_provisioning_data.instance_network internal_ip = default_job_provisioning_data.internal_ip @@ -295,7 +296,7 @@ def _deploy_instance( healthcheck_out = get_shim_healthcheck(client) try: - healthcheck = HealthcheckResponse.__response__.parse_raw(healthcheck_out) + healthcheck = validate_json_extra_ignore(HealthcheckResponse, healthcheck_out) except ValueError as exc: raise SSHProvisioningError(f"Cannot parse HealthcheckResponse: {exc}") from exc instance_check = runner_client.healthcheck_response_to_instance_check(healthcheck) diff --git a/src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py b/src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py index af65d4c485..720b0141ba 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py @@ -13,7 +13,11 @@ from dstack._internal.core.consts import DSTACK_RUNNER_HTTP_PORT, DSTACK_SHIM_HTTP_PORT from dstack._internal.core.errors import GatewayError, SSHError -from dstack._internal.core.models.common import NetworkMode, RegistryAuth +from dstack._internal.core.models.common import ( + NetworkMode, + RegistryAuth, + validate_json_extra_ignore, +) from dstack._internal.core.models.configurations import ( DevEnvironmentConfiguration, ServiceConfiguration, @@ -413,7 +417,7 @@ async def _load_process_context(item: JobRunningPipelineItem) -> Optional[_Proce # gate in _prepare_startup_context can read its status / IP. # _fetch_run_model handles both: same-replica jobs always, plus # all non-terminated jobs when one exists. - run_spec = RunSpec.__response__.parse_raw(job_model.run.run_spec) + run_spec = validate_json_extra_ignore(RunSpec, job_model.run.run_spec) run_model = await _fetch_run_model( session=session, run_id=job_model.run_id, @@ -872,7 +876,9 @@ async def _process_pulling_status( _set_job_runtime_data(result, shim_state.job_runtime_data) if shim_state.image_pull_progress is not None: - result.job_update_map["image_pull_progress"] = shim_state.image_pull_progress.json() + result.job_update_map["image_pull_progress"] = ( + shim_state.image_pull_progress.model_dump_json() + ) if shim_state.state == _ShimPullingState.WAITING: _reset_disconnected_at(context.job_model, result) @@ -1215,7 +1221,7 @@ async def _register_service_replica( if context.run_model.gateway_id is None: return None - job_spec = JobSpec.__response__.parse_raw(context.job_model.job_spec_data) + job_spec = validate_json_extra_ignore(JobSpec, context.job_model.job_spec_data) # For router-based services (e.g. PD disaggregation), only router replicas should be # registered with the gateway. Worker replicas are discovered by the router-worker @@ -1242,7 +1248,7 @@ async def _register_service_replica( instance_project_ssh_private_key = context.job_model.instance.project.ssh_private_key # JobRuntimeData might change on PULLING -> RUNNING path # so we must update job_submission with the result value. - job_submission = context.job_submission.copy(deep=True) + job_submission = context.job_submission.model_copy(deep=True) job_submission.job_runtime_data = _get_result_job_runtime_data(context.job_model, result) for conn in connections: try: @@ -1391,7 +1397,7 @@ def _process_provisioning_with_shim( instance_mounts: list[InstanceMountPoint] = [] for mount in run.run_spec.configuration.volumes: if isinstance(mount, VolumeMountPoint): - volume_mounts.append(mount.copy()) + volume_mounts.append(mount.model_copy()) elif isinstance(mount, InstanceMountPoint): instance_mounts.append(mount) else: @@ -1513,7 +1519,7 @@ def _sync_shim_pulling_state( task.termination_reason, task.termination_message, ) - logger.debug("task status: %s", task.dict()) + logger.debug("task status: %s", task.model_dump()) return _SyncShimPullingStateResult( state=_ShimPullingState.FAILED, termination_reason=JobTerminationReason(task.termination_reason.lower()), @@ -1533,7 +1539,7 @@ def _sync_shim_pulling_state( state=_ShimPullingState.WAITING, image_pull_progress=image_pull_progress, ) - jrd = jrd.copy(update={"ports": {pm.container: pm.host for pm in task.ports}}) + jrd = jrd.model_copy(update={"ports": {pm.container: pm.host for pm in task.ports}}) else: shim_status = shim_client.pull() if ( @@ -1547,7 +1553,7 @@ def _sync_shim_pulling_state( shim_status.result.reason, shim_status.result.reason_message, ) - logger.debug("shim status: %s", shim_status.dict()) + logger.debug("shim status: %s", shim_status.model_dump()) return _SyncShimPullingStateResult( state=_ShimPullingState.FAILED, termination_reason=JobTerminationReason(shim_status.result.reason.lower()), @@ -1630,7 +1636,7 @@ def _submit_job_to_runner( job_info = runner_client.run_job() if job_info is not None: if jrd is not None: - jrd = jrd.copy( + jrd = jrd.model_copy( update={"working_dir": job_info.working_dir, "username": job_info.username} ) return _SubmitJobToRunnerResult( @@ -1708,7 +1714,7 @@ def _terminate_if_inactivity_duration_exceeded( job_update_map: _JobUpdateMap, no_connections_secs: Optional[int], ) -> None: - conf = RunSpec.__response__.parse_raw(run_model.run_spec).configuration + conf = validate_json_extra_ignore(RunSpec, run_model.run_spec).configuration if not isinstance(conf, DevEnvironmentConfiguration) or not isinstance( conf.inactivity_duration, int ): @@ -1896,7 +1902,7 @@ def _set_job_status(job_model: JobModel, result: _ProcessResult, new_status: Job def _set_job_runtime_data(result: _ProcessResult, jrd: Optional[JobRuntimeData]) -> None: - result.job_update_map["job_runtime_data"] = None if jrd is None else jrd.json() + result.job_update_map["job_runtime_data"] = None if jrd is None else jrd.model_dump_json() def _apply_submit_job_to_runner_result( @@ -1929,7 +1935,7 @@ def _get_result_job_runtime_data( jrd = result.job_update_map.get("job_runtime_data", job_model.job_runtime_data) if jrd is None: return None - return JobRuntimeData.__response__.parse_raw(jrd) + return validate_json_extra_ignore(JobRuntimeData, jrd) def _get_result_registered(job_model: JobModel, result: _ProcessResult) -> bool: diff --git a/src/dstack/_internal/server/background/pipeline_tasks/jobs_submitted.py b/src/dstack/_internal/server/background/pipeline_tasks/jobs_submitted.py index 1824e0070e..74e1031c5f 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/jobs_submitted.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/jobs_submitted.py @@ -21,7 +21,7 @@ BACKENDS_WITH_PLACEMENT_GROUPS_SUPPORT, ) from dstack._internal.core.errors import BackendError, ServerClientError, SkipOffer -from dstack._internal.core.models.common import NetworkMode +from dstack._internal.core.models.common import NetworkMode, validate_extra_ignore from dstack._internal.core.models.compute_groups import ( ComputeGroupProvisioningData, ComputeGroupStatus, @@ -891,8 +891,8 @@ def _get_master_job_provisioning_data( if master_job.job_submissions[-1].job_provisioning_data is None: return None - return JobProvisioningData.__response__.parse_obj( - master_job.job_submissions[-1].job_provisioning_data + return validate_extra_ignore( + JobProvisioningData, master_job.job_submissions[-1].job_provisioning_data ) @@ -1178,7 +1178,7 @@ def _assign_instance_to_job( job_model.instance = instance_model job_model.used_instance_id = instance_model.id job_model.job_provisioning_data = instance_model.job_provisioning_data - job_model.job_runtime_data = _prepare_job_runtime_data(offer, multinode).json() + job_model.job_runtime_data = _prepare_job_runtime_data(offer, multinode).model_dump_json() job_model.skip_min_processing_interval = True switch_instance_status(session, instance_model, InstanceStatus.BUSY) @@ -1360,7 +1360,7 @@ async def _apply_existing_instance_provisioning( context.job_model.job_runtime_data = _prepare_job_runtime_data( offer=get_or_error(get_instance_offer(instance_model)), multinode=context.multinode, - ).json() + ).model_dump_json() switch_job_status(session, context.job_model, JobStatus.PROVISIONING) await _apply_volume_attachment_result( session=session, @@ -1580,7 +1580,7 @@ def _resolve_provisioned_jobs_and_data( project=context.project, fleet=fleet_model, status=ComputeGroupStatus.RUNNING, - provisioning_data=provisioning_data.json(), + provisioning_data=provisioning_data.model_dump_json(), ) return ( context.jobs_to_provision, @@ -1611,7 +1611,7 @@ async def _promote_or_create_instance_models_for_provisioned_jobs( provisioned_job_models, job_provisioning_datas ): provisioned_job_model.fleet_id = fleet_model.id - provisioned_job_model.job_provisioning_data = job_provisioning_data.json() + provisioned_job_model.job_provisioning_data = job_provisioning_data.model_dump_json() switch_job_status(session, provisioned_job_model, JobStatus.PROVISIONING) provisioned_job_model.skip_min_processing_interval = True @@ -1646,7 +1646,7 @@ async def _promote_or_create_instance_models_for_provisioned_jobs( instance_models.append(instance_model) provisioned_job_model.job_runtime_data = _prepare_job_runtime_data( offer, context.multinode - ).json() + ).model_dump_json() events.emit( session, f"Instance provisioned for job. Instance status: {instance_model.status.upper()}", @@ -1714,8 +1714,8 @@ def _create_instance_model_for_job( started_at=get_current_datetime(), status=InstanceStatus.PROVISIONING, unreachable=False, - job_provisioning_data=job_provisioning_data.json(), - offer=offer.json(), + job_provisioning_data=job_provisioning_data.model_dump_json(), + offer=offer.model_dump_json(), termination_policy=termination_policy, termination_idle_time=termination_idle_time, jobs=[job_model], @@ -1747,8 +1747,8 @@ def _promote_placeholder_instance( instance_model.status = InstanceStatus.PROVISIONING instance_model.started_at = get_current_datetime() instance_model.compute_group = compute_group_model - instance_model.job_provisioning_data = job_provisioning_data.json() - instance_model.offer = offer.json() + instance_model.job_provisioning_data = job_provisioning_data.model_dump_json() + instance_model.offer = offer.model_dump_json() instance_model.backend = offer.backend instance_model.price = offer.price instance_model.region = offer.region @@ -1822,7 +1822,7 @@ async def _process_volume_attachments( attachments.append( _VolumeAttachmentPayload( volume_id=volume_model.id, - attachment_data=attachment_data.json(), + attachment_data=attachment_data.model_dump_json(), volume_name=volume.name, ) ) @@ -1990,7 +1990,7 @@ async def _apply_volume_attachment_result( job_runtime_data.volume_names = [ attachment.volume_name for attachment in volume_attachment_result.attachments ] - job_model.job_runtime_data = job_runtime_data.json() + job_model.job_runtime_data = job_runtime_data.model_dump_json() volume_ids = [attachment.volume_id for attachment in volume_attachment_result.attachments] if volume_ids: diff --git a/src/dstack/_internal/server/background/pipeline_tasks/volumes.py b/src/dstack/_internal/server/background/pipeline_tasks/volumes.py index 66dabdeb07..a5d08ac803 100644 --- a/src/dstack/_internal/server/background/pipeline_tasks/volumes.py +++ b/src/dstack/_internal/server/background/pipeline_tasks/volumes.py @@ -366,7 +366,7 @@ async def _process_submitted_volume(volume_model: VolumeModel) -> _ProcessResult return _ProcessResult( update_map={ "status": VolumeStatus.ACTIVE, - "volume_provisioning_data": vpd.json(), + "volume_provisioning_data": vpd.model_dump_json(), } ) diff --git a/src/dstack/_internal/server/background/scheduled_tasks/idle_volumes.py b/src/dstack/_internal/server/background/scheduled_tasks/idle_volumes.py index e1625131e1..cf41b977b1 100644 --- a/src/dstack/_internal/server/background/scheduled_tasks/idle_volumes.py +++ b/src/dstack/_internal/server/background/scheduled_tasks/idle_volumes.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from dstack._internal.core.models.profiles import parse_duration +from dstack._internal.core.models.duration import parse_duration from dstack._internal.core.models.volumes import VolumeStatus from dstack._internal.server.db import get_db, get_session_ctx from dstack._internal.server.models import ProjectModel, UserModel, VolumeModel diff --git a/src/dstack/_internal/server/models.py b/src/dstack/_internal/server/models.py index a70ea9e5e6..72c9745422 100644 --- a/src/dstack/_internal/server/models.py +++ b/src/dstack/_internal/server/models.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import Callable, Generic, List, Optional, TypeVar, Union +from pydantic import ConfigDict from sqlalchemy import ( BigInteger, Boolean, @@ -24,7 +25,7 @@ from dstack._internal.core.errors import DstackError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreConfig, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.compute_groups import ComputeGroupStatus from dstack._internal.core.models.events import EventTargetType from dstack._internal.core.models.fleets import FleetStatus @@ -76,11 +77,9 @@ def process_result_value(self, value, dialect): return value.replace(tzinfo=timezone.utc) -class DecryptedStringConfig(CoreConfig): - arbitrary_types_allowed = True +class DecryptedString(CoreModel): + model_config = ConfigDict(arbitrary_types_allowed=True) - -class DecryptedString(generate_dual_core_model(DecryptedStringConfig)): """ A type for representing plaintext strings encrypted with `EncryptedString`. Besides the string, stores information if the decryption was successful. diff --git a/src/dstack/_internal/server/routers/auth.py b/src/dstack/_internal/server/routers/auth.py index e44fb67f53..042739f89c 100644 --- a/src/dstack/_internal/server/routers/auth.py +++ b/src/dstack/_internal/server/routers/auth.py @@ -6,7 +6,7 @@ OAuthGetNextRedirectResponse, ) from dstack._internal.server.services import auth as auth_services -from dstack._internal.server.utils.routers import CustomORJSONResponse +from dstack._internal.server.utils.routers import CustomJSONResponse router = APIRouter(prefix="/api/auth", tags=["authentication"]) @@ -18,7 +18,7 @@ async def list_providers(): """ Returns OAuth2 providers registered on the server. """ - return CustomORJSONResponse(auth_services.list_providers()) + return CustomJSONResponse(auth_services.list_providers()) @router.post( @@ -33,7 +33,7 @@ async def get_next_redirect(body: OAuthGetNextRedirectRequest): to determine if the user needs to be redirected further (CLI login) or the auth callback endpoint needs to be called directly (UI login). """ - return CustomORJSONResponse( + return CustomJSONResponse( OAuthGetNextRedirectResponse( redirect_url=auth_services.get_next_redirect_url(code=body.code, state=body.state) ) diff --git a/src/dstack/_internal/server/routers/backends.py b/src/dstack/_internal/server/routers/backends.py index 4fd84a4477..8c79993269 100644 --- a/src/dstack/_internal/server/routers/backends.py +++ b/src/dstack/_internal/server/routers/backends.py @@ -28,7 +28,7 @@ update_backend_config_yaml, ) from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -46,7 +46,7 @@ @root_router.post("/list_types", summary="List backend types", response_model=List[BackendType]) async def list_backend_types(): - return CustomORJSONResponse( + return CustomJSONResponse( dstack._internal.core.backends.configurators.list_available_backend_types() ) @@ -61,7 +61,7 @@ async def create_backend( config = await backends.create_backend(session=session, project=project, config=body) if settings.SERVER_CONFIG_ENABLED: await ServerConfigManager().sync_config(session=session) - return CustomORJSONResponse(config) + return CustomJSONResponse(config) @project_router.post("/update", summary="Update backend", response_model=AnyBackendConfigWithCreds) @@ -74,7 +74,7 @@ async def update_backend( config = await backends.update_backend(session=session, project=project, config=body) if settings.SERVER_CONFIG_ENABLED: await ServerConfigManager().sync_config(session=session) - return CustomORJSONResponse(config) + return CustomJSONResponse(config) @project_router.post("/delete", summary="Delete backends") @@ -104,7 +104,7 @@ async def get_backend_config_info( config = await backends.get_backend_config(project=project, backend_type=backend_name) if config is None: raise ResourceNotExistsError() - return CustomORJSONResponse(config) + return CustomJSONResponse(config) @project_router.post("/create_yaml", summary="Create backend YAML") @@ -143,6 +143,6 @@ async def get_backend_yaml( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()), ): _, project = user_project - return CustomORJSONResponse( + return CustomJSONResponse( await get_backend_config_yaml(project=project, backend_type=backend_name) ) diff --git a/src/dstack/_internal/server/routers/events.py b/src/dstack/_internal/server/routers/events.py index 1d6d80b671..574a59bac8 100644 --- a/src/dstack/_internal/server/routers/events.py +++ b/src/dstack/_internal/server/routers/events.py @@ -8,7 +8,7 @@ from dstack._internal.server.schemas.events import ListEventsRequest from dstack._internal.server.security.permissions import Authenticated from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -38,7 +38,7 @@ async def list_events( This should be taken into account when using the API to monitor recent events, so that delayed events are not missed during pagination. """ - return CustomORJSONResponse( + return CustomJSONResponse( await events_services.list_events( session=session, user=user, diff --git a/src/dstack/_internal/server/routers/files.py b/src/dstack/_internal/server/routers/files.py index 5456d3d5d5..c255e600a9 100644 --- a/src/dstack/_internal/server/routers/files.py +++ b/src/dstack/_internal/server/routers/files.py @@ -12,7 +12,7 @@ from dstack._internal.server.services import files from dstack._internal.server.settings import SERVER_CODE_UPLOAD_LIMIT from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, get_request_size, ) @@ -40,7 +40,7 @@ async def get_archive_by_hash( ) if archive is None: raise ResourceNotExistsError() - return CustomORJSONResponse(archive) + return CustomJSONResponse(archive) @router.post("/upload_archive", summary="Upload file archive", response_model=FileArchive) @@ -67,4 +67,4 @@ async def upload_archive( user=user, file=file, ) - return CustomORJSONResponse(archive) + return CustomJSONResponse(archive) diff --git a/src/dstack/_internal/server/routers/fleets.py b/src/dstack/_internal/server/routers/fleets.py index b4131ee022..5abf7ce19a 100644 --- a/src/dstack/_internal/server/routers/fleets.py +++ b/src/dstack/_internal/server/routers/fleets.py @@ -28,7 +28,7 @@ ) from dstack._internal.server.services.pipelines import PipelineHinterProtocol, get_pipeline_hinter from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, get_client_version, ) @@ -73,7 +73,7 @@ async def list_fleets( ) for fleet in fleet_list: patch_fleet(fleet, client_version) - return CustomORJSONResponse(fleet_list) + return CustomJSONResponse(fleet_list) @project_router.post("/list", summary="List project fleets", response_model=List[Fleet]) @@ -97,7 +97,7 @@ async def list_project_fleets( ) for fleet in fleet_list: patch_fleet(fleet, client_version) - return CustomORJSONResponse(fleet_list) + return CustomJSONResponse(fleet_list) @project_router.post("/get", summary="Get fleet", response_model=Fleet) @@ -122,7 +122,7 @@ async def get_fleet( if fleet is None: raise ResourceNotExistsError() patch_fleet(fleet, client_version) - return CustomORJSONResponse(fleet) + return CustomJSONResponse(fleet) @project_router.post("/get_plan", summary="Get fleet plan", response_model=FleetPlan) @@ -143,7 +143,7 @@ async def get_plan( spec=body.spec, ) patch_fleet_plan(plan, client_version) - return CustomORJSONResponse(plan) + return CustomJSONResponse(plan) @project_router.post("/apply", summary="Apply fleet plan", response_model=Fleet) @@ -169,7 +169,7 @@ async def apply_plan( pipeline_hinter=pipeline_hinter, ) patch_fleet(fleet, client_version) - return CustomORJSONResponse(fleet) + return CustomJSONResponse(fleet) @project_router.post("/create", summary="Create fleet", response_model=Fleet, deprecated=True) @@ -192,7 +192,7 @@ async def create_fleet( pipeline_hinter=pipeline_hinter, ) patch_fleet(fleet, client_version) - return CustomORJSONResponse(fleet) + return CustomJSONResponse(fleet) @project_router.post("/delete", summary="Delete fleets") diff --git a/src/dstack/_internal/server/routers/gateways.py b/src/dstack/_internal/server/routers/gateways.py index 134ce04eae..26764a81b9 100644 --- a/src/dstack/_internal/server/routers/gateways.py +++ b/src/dstack/_internal/server/routers/gateways.py @@ -21,7 +21,7 @@ ) from dstack._internal.server.services.pipelines import PipelineHinterProtocol, get_pipeline_hinter from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, get_client_version, ) @@ -50,7 +50,7 @@ async def list_gateways( ) for gateway in gateway_list: patch_gateway(gateway, client_version) - return CustomORJSONResponse(gateway_list) + return CustomJSONResponse(gateway_list) @router.post("/get", summary="Get gateway", response_model=models.Gateway) @@ -68,7 +68,7 @@ async def get_gateway( if gateway is None: raise ResourceNotExistsError() patch_gateway(gateway, client_version) - return CustomORJSONResponse(gateway) + return CustomJSONResponse(gateway) @router.post("/get_plan", summary="Get gateway plan", response_model=models.GatewayPlan) @@ -90,7 +90,7 @@ async def get_plan( spec=body.spec, ) patch_gateway_plan(plan, client_version) - return CustomORJSONResponse(plan) + return CustomJSONResponse(plan) @router.post("/apply", summary="Apply gateway plan", response_model=models.Gateway) @@ -114,7 +114,7 @@ async def apply_plan( pipeline_hinter=pipeline_hinter, ) patch_gateway(gateway, client_version) - return CustomORJSONResponse(gateway) + return CustomJSONResponse(gateway) @router.post("/create", summary="Create gateway", response_model=models.Gateway, deprecated=True) @@ -137,7 +137,7 @@ async def create_gateway( pipeline_hinter=pipeline_hinter, ) patch_gateway(gateway, client_version) - return CustomORJSONResponse(gateway) + return CustomJSONResponse(gateway) @router.post("/delete", summary="Delete gateways") @@ -194,4 +194,4 @@ async def set_gateway_wildcard_domain( user=user, ) patch_gateway(gateway, client_version) - return CustomORJSONResponse(gateway) + return CustomJSONResponse(gateway) diff --git a/src/dstack/_internal/server/routers/instances.py b/src/dstack/_internal/server/routers/instances.py index 4b96d422c2..ebe3b8c390 100644 --- a/src/dstack/_internal/server/routers/instances.py +++ b/src/dstack/_internal/server/routers/instances.py @@ -21,7 +21,7 @@ check_can_access_instance, ) from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -50,7 +50,7 @@ async def list_instances( The results are paginated. To get the next page, pass `created_at` and `id` of the last instance from the previous page as `prev_created_at` and `prev_id`. """ - return CustomORJSONResponse( + return CustomJSONResponse( await instances_services.list_user_instances( session=session, user=user, @@ -82,7 +82,7 @@ async def get_instance_health_checks( before=body.before, limit=body.limit, ) - return CustomORJSONResponse(GetInstanceHealthChecksResponse(health_checks=health_checks)) + return CustomJSONResponse(GetInstanceHealthChecksResponse(health_checks=health_checks)) @project_router.post("/get", response_model=Instance) @@ -103,4 +103,4 @@ async def get_instance( ) if instance is None: raise ResourceNotExistsError() - return CustomORJSONResponse(instance) + return CustomJSONResponse(instance) diff --git a/src/dstack/_internal/server/routers/logs.py b/src/dstack/_internal/server/routers/logs.py index 540c4ec125..fa51787133 100644 --- a/src/dstack/_internal/server/routers/logs.py +++ b/src/dstack/_internal/server/routers/logs.py @@ -8,7 +8,7 @@ from dstack._internal.server.security.permissions import ProjectMember from dstack._internal.server.services import logs from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -32,4 +32,4 @@ async def poll_logs( # The runner guarantees logs have different timestamps if throughput < 1k logs / sec. # Otherwise, some logs with duplicated timestamps may be filtered out. # This limitation is imposed by cloud log services that support up to millisecond timestamp resolution. - return CustomORJSONResponse(await logs.poll_logs_async(project=project, request=body)) + return CustomJSONResponse(await logs.poll_logs_async(project=project, request=body)) diff --git a/src/dstack/_internal/server/routers/metrics.py b/src/dstack/_internal/server/routers/metrics.py index 14d0eb6fb5..b44d4a86a3 100644 --- a/src/dstack/_internal/server/routers/metrics.py +++ b/src/dstack/_internal/server/routers/metrics.py @@ -13,7 +13,7 @@ from dstack._internal.server.services import metrics from dstack._internal.server.services.jobs import get_run_job_model from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -72,7 +72,7 @@ async def get_job_metrics( if job_model is None: raise ResourceNotExistsError("Found no job with given parameters") - return CustomORJSONResponse( + return CustomJSONResponse( await metrics.get_job_metrics( session=session, job_model=job_model, diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index f7d9098dfc..a3b8f8ba2f 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -25,7 +25,7 @@ ) from dstack._internal.server.services import fleets, projects from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -53,7 +53,7 @@ async def list_projects( if body is None: # For backward compatibility body = ListProjectsRequest() - return CustomORJSONResponse( + return CustomJSONResponse( await projects.list_user_accessible_projects( session=session, user=user, @@ -86,7 +86,7 @@ async def list_only_no_fleets( `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. """ - return CustomORJSONResponse( + return CustomJSONResponse( await fleets.list_projects_with_no_active_fleets(session=session, user=user) ) @@ -97,7 +97,7 @@ async def create_project( session: AsyncSession = Depends(get_session), user: UserModel = Depends(Authenticated()), ): - return CustomORJSONResponse( + return CustomJSONResponse( await projects.create_project( session=session, user=user, @@ -127,7 +127,7 @@ async def get_project( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ): _, project = user_project - return CustomORJSONResponse(projects.project_model_to_project(project)) + return CustomJSONResponse(projects.project_model_to_project(project)) @router.post( @@ -148,7 +148,7 @@ async def set_project_members( members=body.members, ) await session.refresh(project) - return CustomORJSONResponse(projects.project_model_to_project(project)) + return CustomJSONResponse(projects.project_model_to_project(project)) @router.post( @@ -169,7 +169,7 @@ async def add_project_members( members=body.members, ) await session.refresh(project) - return CustomORJSONResponse(projects.project_model_to_project(project)) + return CustomJSONResponse(projects.project_model_to_project(project)) @router.post( @@ -190,7 +190,7 @@ async def remove_project_members( usernames=body.usernames, ) await session.refresh(project) - return CustomORJSONResponse(projects.project_model_to_project(project)) + return CustomJSONResponse(projects.project_model_to_project(project)) @router.post( @@ -213,4 +213,4 @@ async def update_project( reset_templates_repo=body.reset_templates_repo, ) await session.refresh(project) - return CustomORJSONResponse(projects.project_model_to_project(project)) + return CustomJSONResponse(projects.project_model_to_project(project)) diff --git a/src/dstack/_internal/server/routers/public_keys.py b/src/dstack/_internal/server/routers/public_keys.py index e846d15415..998d38bc00 100644 --- a/src/dstack/_internal/server/routers/public_keys.py +++ b/src/dstack/_internal/server/routers/public_keys.py @@ -13,7 +13,7 @@ from dstack._internal.server.security.permissions import Authenticated from dstack._internal.server.services import public_keys as public_keys_services from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -30,7 +30,7 @@ async def list_user_public_keys( user: Annotated[UserModel, Depends(Authenticated())], ): public_keys = await public_keys_services.list_user_public_keys(session=session, user=user) - return CustomORJSONResponse(public_keys) + return CustomJSONResponse(public_keys) @router.post("/add", summary="Add SSH key", response_model=PublicKeyInfo) @@ -42,7 +42,7 @@ async def add_user_public_key( public_key = await public_keys_services.add_user_public_key( session=session, user=user, key=body.key, name=body.name ) - return CustomORJSONResponse(public_key) + return CustomJSONResponse(public_key) @router.post("/delete", summary="Delete SSH keys") diff --git a/src/dstack/_internal/server/routers/repos.py b/src/dstack/_internal/server/routers/repos.py index 95307bd15c..c45063d1b7 100644 --- a/src/dstack/_internal/server/routers/repos.py +++ b/src/dstack/_internal/server/routers/repos.py @@ -16,7 +16,7 @@ from dstack._internal.server.services import repos from dstack._internal.server.settings import SERVER_CODE_UPLOAD_LIMIT from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, get_request_size, ) @@ -35,7 +35,7 @@ async def list_repos( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), ): _, project = user_project - return CustomORJSONResponse(await repos.list_repos(session=session, project=project)) + return CustomJSONResponse(await repos.list_repos(session=session, project=project)) @router.post("/get", summary="Get repo", response_model=RepoHeadWithCreds) @@ -54,7 +54,7 @@ async def get_repo( ) if repo is None: raise ResourceNotExistsError() - return CustomORJSONResponse(repo) + return CustomJSONResponse(repo) @router.post("/init", summary="Initialize repo") diff --git a/src/dstack/_internal/server/routers/runs.py b/src/dstack/_internal/server/routers/runs.py index 994b91fa29..c2434e1469 100644 --- a/src/dstack/_internal/server/routers/runs.py +++ b/src/dstack/_internal/server/routers/runs.py @@ -23,7 +23,7 @@ from dstack._internal.server.services import runs, users from dstack._internal.server.services.pipelines import PipelineHinterProtocol, get_pipeline_hinter from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, get_client_version, ) @@ -86,7 +86,7 @@ async def list_runs( ) for run in run_list: patch_run(run, client_version) - return CustomORJSONResponse(run_list) + return CustomJSONResponse(run_list) @project_router.post("/get", response_model=Run, summary="Get run") @@ -111,7 +111,7 @@ async def get_run( if run is None: raise ResourceNotExistsError("Run not found") patch_run(run, client_version) - return CustomORJSONResponse(run) + return CustomJSONResponse(run) @project_router.post( @@ -144,7 +144,7 @@ async def get_plan( legacy_repo_dir=legacy_repo_dir, ) patch_run_plan(run_plan, client_version) - return CustomORJSONResponse(run_plan) + return CustomJSONResponse(run_plan) @project_router.post("/apply", response_model=Run, summary="Apply run plan") @@ -175,7 +175,7 @@ async def apply_plan( legacy_repo_dir=legacy_repo_dir, ) patch_run(run, client_version) - return CustomORJSONResponse(run) + return CustomJSONResponse(run) @project_router.post("/stop", summary="Stop runs") diff --git a/src/dstack/_internal/server/routers/secrets.py b/src/dstack/_internal/server/routers/secrets.py index db4d364b36..c1b8ec0f79 100644 --- a/src/dstack/_internal/server/routers/secrets.py +++ b/src/dstack/_internal/server/routers/secrets.py @@ -14,7 +14,7 @@ ) from dstack._internal.server.security.permissions import ProjectManager from dstack._internal.server.services import secrets as secrets_services -from dstack._internal.server.utils.routers import CustomORJSONResponse +from dstack._internal.server.utils.routers import CustomJSONResponse router = APIRouter( prefix="/api/project/{project_name}/secrets", @@ -28,7 +28,7 @@ async def list_secrets( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()), ): user, project = user_project - return CustomORJSONResponse( + return CustomJSONResponse( await secrets_services.list_secrets( session=session, project=project, @@ -52,7 +52,7 @@ async def get_secret( ) if secret is None: raise ResourceNotExistsError() - return CustomORJSONResponse(secret) + return CustomJSONResponse(secret) @router.post("/create_or_update", summary="Create or update secret", response_model=Secret) @@ -62,7 +62,7 @@ async def create_or_update_secret( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()), ): user, project = user_project - return CustomORJSONResponse( + return CustomJSONResponse( await secrets_services.create_or_update_secret( session=session, project=project, diff --git a/src/dstack/_internal/server/routers/server.py b/src/dstack/_internal/server/routers/server.py index 8fd3d77d46..2d2e7123fb 100644 --- a/src/dstack/_internal/server/routers/server.py +++ b/src/dstack/_internal/server/routers/server.py @@ -2,7 +2,7 @@ from dstack._internal import settings from dstack._internal.core.models.server import ServerInfo -from dstack._internal.server.utils.routers import CustomORJSONResponse +from dstack._internal.server.utils.routers import CustomJSONResponse router = APIRouter( prefix="/api/server", @@ -12,7 +12,7 @@ @router.post("/get_info", summary="Get server info", response_model=ServerInfo) async def get_server_info(): - return CustomORJSONResponse( + return CustomJSONResponse( ServerInfo( server_version=settings.DSTACK_VERSION, ) diff --git a/src/dstack/_internal/server/routers/sshproxy.py b/src/dstack/_internal/server/routers/sshproxy.py index 0baeb0f0ed..3c472fbc73 100644 --- a/src/dstack/_internal/server/routers/sshproxy.py +++ b/src/dstack/_internal/server/routers/sshproxy.py @@ -10,7 +10,7 @@ from dstack._internal.server.security.permissions import AlwaysForbidden, ServiceAccount from dstack._internal.server.services.sshproxy.handlers import get_upstream_response from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -36,4 +36,4 @@ async def get_upstream( response = await get_upstream_response(session=session, upstream_id=body.id) if response is None: raise ResourceNotExistsError() - return CustomORJSONResponse(response) + return CustomJSONResponse(response) diff --git a/src/dstack/_internal/server/routers/templates.py b/src/dstack/_internal/server/routers/templates.py index 99af9f273c..9349161757 100644 --- a/src/dstack/_internal/server/routers/templates.py +++ b/src/dstack/_internal/server/routers/templates.py @@ -6,7 +6,7 @@ from dstack._internal.server.models import ProjectModel, UserModel from dstack._internal.server.security.permissions import ProjectMember from dstack._internal.server.services import templates as templates_service -from dstack._internal.server.utils.routers import CustomORJSONResponse +from dstack._internal.server.utils.routers import CustomJSONResponse router = APIRouter( prefix="/api/project/{project_name}/templates", @@ -19,4 +19,4 @@ async def list_templates( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), ): _, project = user_project - return CustomORJSONResponse(await templates_service.list_templates(project)) + return CustomJSONResponse(await templates_service.list_templates(project)) diff --git a/src/dstack/_internal/server/routers/users.py b/src/dstack/_internal/server/routers/users.py index e26ce6dfbe..c5af436b32 100644 --- a/src/dstack/_internal/server/routers/users.py +++ b/src/dstack/_internal/server/routers/users.py @@ -18,7 +18,7 @@ from dstack._internal.server.security.permissions import Authenticated, GlobalAdmin from dstack._internal.server.services import events, users from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -46,7 +46,7 @@ async def list_users( if body is None: # For backward compatibility body = ListUsersRequest() - return CustomORJSONResponse( + return CustomJSONResponse( await users.list_users_for_user( session=session, user=user, @@ -68,7 +68,7 @@ async def get_my_user( if user.ssh_private_key is None or user.ssh_public_key is None: # Generate keys for pre-0.19.33 users await users.refresh_ssh_key(session=session, actor=user) - return CustomORJSONResponse(users.user_model_to_user_with_creds(user)) + return CustomJSONResponse(users.user_model_to_user_with_creds(user)) @router.post("/get_user", summary="Get user", response_model=UserWithCreds) @@ -82,7 +82,7 @@ async def get_user( ) if res is None: raise ResourceNotExistsError() - return CustomORJSONResponse(res) + return CustomJSONResponse(res) @router.post("/create", summary="Create user", response_model=User) @@ -99,7 +99,7 @@ async def create_user( active=body.active, creator=user, ) - return CustomORJSONResponse(users.user_model_to_user(res)) + return CustomJSONResponse(users.user_model_to_user(res)) @router.post("/update", summary="Update user", response_model=User) @@ -118,7 +118,7 @@ async def update_user( ) if res is None: raise ResourceNotExistsError() - return CustomORJSONResponse(users.user_model_to_user(res)) + return CustomJSONResponse(users.user_model_to_user(res)) @router.post("/refresh_ssh_key", summary="Refresh SSH key", response_model=UserWithCreds) @@ -130,7 +130,7 @@ async def refresh_ssh_key( res = await users.refresh_ssh_key(session=session, actor=user, username=body.username) if res is None: raise ResourceNotExistsError() - return CustomORJSONResponse(users.user_model_to_user_with_creds(res)) + return CustomJSONResponse(users.user_model_to_user_with_creds(res)) @router.post("/refresh_token", summary="Refresh token", response_model=UserWithCreds) @@ -142,7 +142,7 @@ async def refresh_token( res = await users.refresh_user_token(session=session, actor=user, username=body.username) if res is None: raise ResourceNotExistsError() - return CustomORJSONResponse(users.user_model_to_user_with_creds(res)) + return CustomJSONResponse(users.user_model_to_user_with_creds(res)) @router.post("/delete", summary="Delete users") diff --git a/src/dstack/_internal/server/routers/volumes.py b/src/dstack/_internal/server/routers/volumes.py index 47db390118..8daee52bd4 100644 --- a/src/dstack/_internal/server/routers/volumes.py +++ b/src/dstack/_internal/server/routers/volumes.py @@ -17,7 +17,7 @@ from dstack._internal.server.security.permissions import Authenticated, ProjectMember from dstack._internal.server.services.pipelines import PipelineHinterProtocol, get_pipeline_hinter from dstack._internal.server.utils.routers import ( - CustomORJSONResponse, + CustomJSONResponse, get_base_api_additional_responses, ) @@ -42,7 +42,7 @@ async def list_volumes( The results are paginated. To get the next page, pass `created_at` and `id` of the last fleet from the previous page as `prev_created_at` and `prev_id`. """ - return CustomORJSONResponse( + return CustomJSONResponse( await volumes_services.list_volumes( session=session, user=user, @@ -65,7 +65,7 @@ async def list_project_volumes( Returns all volumes in the project. """ _, project = user_project - return CustomORJSONResponse( + return CustomJSONResponse( await volumes_services.list_project_volumes(session=session, project=project) ) @@ -85,7 +85,7 @@ async def get_volume( ) if volume is None: raise ResourceNotExistsError() - return CustomORJSONResponse(volume) + return CustomJSONResponse(volume) @project_router.post("/create", summary="Create volume", response_model=Volume) @@ -99,7 +99,7 @@ async def create_volume( Creates a volume given a volume configuration. """ user, project = user_project - return CustomORJSONResponse( + return CustomJSONResponse( await volumes_services.create_volume( session=session, project=project, diff --git a/src/dstack/_internal/server/schemas/events.py b/src/dstack/_internal/server/schemas/events.py index 3899b1f398..c3af2a5dae 100644 --- a/src/dstack/_internal/server/schemas/events.py +++ b/src/dstack/_internal/server/schemas/events.py @@ -3,7 +3,8 @@ from typing import Annotated, Optional from uuid import UUID -from pydantic import Field, root_validator +from pydantic import Field, model_validator +from typing_extensions import Self from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.events import EventTargetType @@ -21,8 +22,8 @@ class ListEventsRequest(CoreModel): "List of project IDs." " The response will only include events that target the specified projects" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_users: Annotated[ @@ -32,8 +33,8 @@ class ListEventsRequest(CoreModel): "List of user IDs." " The response will only include events that target the specified users" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_fleets: Annotated[ @@ -43,8 +44,8 @@ class ListEventsRequest(CoreModel): "List of fleet IDs." " The response will only include events that target the specified fleets" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_instances: Annotated[ @@ -54,8 +55,8 @@ class ListEventsRequest(CoreModel): "List of instance IDs." " The response will only include events that target the specified instances" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_runs: Annotated[ @@ -65,8 +66,8 @@ class ListEventsRequest(CoreModel): "List of run IDs." " The response will only include events that target the specified runs" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_jobs: Annotated[ @@ -76,8 +77,8 @@ class ListEventsRequest(CoreModel): "List of job IDs." " The response will only include events that target the specified jobs" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_volumes: Annotated[ @@ -87,8 +88,8 @@ class ListEventsRequest(CoreModel): "List of volume IDs." " The response will only include events that target the specified volumes" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_gateways: Annotated[ @@ -98,8 +99,8 @@ class ListEventsRequest(CoreModel): "List of gateway IDs." " The response will only include events that target the specified gateways" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None target_secrets: Annotated[ @@ -109,8 +110,8 @@ class ListEventsRequest(CoreModel): "List of secret IDs." " The response will only include events that target the specified secrets" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None within_projects: Annotated[ @@ -121,8 +122,8 @@ class ListEventsRequest(CoreModel): " The response will only include events that target the specified projects" " or any entities within those projects" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None within_fleets: Annotated[ @@ -133,8 +134,8 @@ class ListEventsRequest(CoreModel): " The response will only include events that target the specified fleets" " or instances within those fleets" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None within_runs: Annotated[ @@ -145,8 +146,8 @@ class ListEventsRequest(CoreModel): " The response will only include events that target the specified runs" " or jobs within those runs" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None include_target_types: Annotated[ @@ -157,8 +158,8 @@ class ListEventsRequest(CoreModel): " The response will only include events that have a target" " of one of the specified types" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None actors: Annotated[ @@ -170,8 +171,8 @@ class ListEventsRequest(CoreModel): " performed by the specified users," " or performed by the system if `null` is specified" ), - min_items=MIN_FILTER_ITEMS, - max_items=MAX_FILTER_ITEMS, + min_length=MIN_FILTER_ITEMS, + max_length=MAX_FILTER_ITEMS, ), ] = None prev_recorded_at: Optional[datetime] = None @@ -179,33 +180,33 @@ class ListEventsRequest(CoreModel): limit: int = Field(LIST_EVENTS_DEFAULT_LIMIT, ge=1, le=100) ascending: bool = False - @root_validator - def _validate_target_filters(cls, values): + @model_validator(mode="after") + def _validate_target_filters(self) -> Self: """ Raise an error if more than one target_* filter is set. Setting multiple target_* filters would always result in an empty response, which might confuse users. """ - target_filters = [name for name in cls.__fields__ if name.startswith("target_")] - set_filters = [f for f in target_filters if values.get(f) is not None] + target_filters = [name for name in type(self).model_fields if name.startswith("target_")] + set_filters = [f for f in target_filters if getattr(self, f) is not None] if len(set_filters) > 1: raise ValueError( f"At most one target_* filter can be set at a time. Got {', '.join(set_filters)}" ) - return values + return self - @root_validator - def _validate_within_filters(cls, values): + @model_validator(mode="after") + def _validate_within_filters(self) -> Self: """ Raise an error if more than one within_* filter is set. Setting multiple within_* filters is either redundant or incorrect. Each within_* filter may also lead to additional db queries, causing unnecessary load. """ - within_filters = [name for name in cls.__fields__ if name.startswith("within_")] - set_filters = [f for f in within_filters if values.get(f) is not None] + within_filters = [name for name in type(self).model_fields if name.startswith("within_")] + set_filters = [f for f in within_filters if getattr(self, f) is not None] if len(set_filters) > 1: raise ValueError( f"At most one within_* filter can be set at a time. Got {', '.join(set_filters)}" ) - return values + return self diff --git a/src/dstack/_internal/server/schemas/gateways.py b/src/dstack/_internal/server/schemas/gateways.py index 6ae30a88d1..3c13ba6ae6 100644 --- a/src/dstack/_internal/server/schemas/gateways.py +++ b/src/dstack/_internal/server/schemas/gateways.py @@ -1,8 +1,8 @@ -from typing import Annotated, Any, Dict, List, Optional +from typing import Annotated, List, Optional from pydantic import Field -from dstack._internal.core.models.common import CoreConfig, CoreModel, generate_dual_core_model +from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.gateways import ( ApplyGatewayPlanInput, GatewayConfiguration, @@ -10,13 +10,7 @@ ) -class CreateGatewayRequestConfig(CoreConfig): - @staticmethod - def schema_extra(schema: Dict[str, Any]): - pass - - -class CreateGatewayRequest(generate_dual_core_model(CreateGatewayRequestConfig)): +class CreateGatewayRequest(CoreModel): configuration: GatewayConfiguration diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index c45624f668..f72b20d8b3 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -19,7 +19,7 @@ class ListProjectsRequest(CoreModel): Optional[str], Field( description="Include only projects with the name containing `name_pattern`.", - regex="^[a-zA-Z0-9-_]*$", + pattern="^[a-zA-Z0-9-_]*$", ), ] = None prev_created_at: Annotated[ diff --git a/src/dstack/_internal/server/schemas/runner.py b/src/dstack/_internal/server/schemas/runner.py index 8363443171..532a8e93fa 100644 --- a/src/dstack/_internal/server/schemas/runner.py +++ b/src/dstack/_internal/server/schemas/runner.py @@ -1,9 +1,8 @@ from base64 import b64decode from enum import Enum -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union -from pydantic import Field, validator -from typing_extensions import Annotated +from pydantic import field_validator from dstack._internal.core.models.common import CoreModel, NetworkMode from dstack._internal.core.models.repos.remote import RemoteRepoCreds @@ -33,7 +32,8 @@ class LogEvent(CoreModel): """`timestamp` is stored in milliseconds.""" message: bytes - @validator("message", pre=True) + @field_validator("message", mode="before") + @classmethod def decode_message(cls, v: Union[str, bytes]) -> bytes: if isinstance(v, str): return b64decode(v) @@ -54,73 +54,74 @@ class JobInfoResponse(CoreModel): username: str +# What the runner is actually sent. This used to be spelled as `Field(include=...)` per field, but +# pydantic v2 removed `include` from `Field` — it is silently ignored there, which would have sent +# the runner every field of `Run`, `JobSpec` and `JobSubmission` instead of these subsets. It lives +# on the model rather than at the call site so that a new caller cannot bypass it. +# +# A name the target model does not declare is ignored: `entrypoint` and `gateway` are listed for +# `job_spec` but `JobSpec` has neither. +_SUBMIT_BODY_INCLUDE: Dict[str, Any] = { + "run": { + "id": True, + "run_spec": { + "run_name", + "repo_id", + "repo_data", + "configuration", + "configuration_path", + }, + }, + "job_spec": { + "replica_num", + "job_num", + "jobs_per_replica", + "user", + "commands", + "entrypoint", + "env", + "gateway", + "single_branch", + "max_duration", + "ssh_key", + "working_dir", + "repo_dir", + "repo_data", + "repo_exists_action", + "file_archives", + }, + "job_submission": {"id"}, + "cluster_info": True, + "secrets": True, + "repo_credentials": True, + "log_quota_hour": True, + "run_spec": { + "run_name", + "repo_id", + "repo_data", + "configuration", + "configuration_path", + }, +} + + class SubmitBody(CoreModel): - run: Annotated[ - Run, - Field( - include={ - "id": True, - "run_spec": { - "run_name", - "repo_id", - "repo_data", - "configuration", - "configuration_path", - }, - } - ), - ] - job_spec: Annotated[ - JobSpec, - Field( - include={ - "replica_num", - "job_num", - "jobs_per_replica", - "user", - "commands", - "entrypoint", - "env", - "gateway", - "single_branch", - "max_duration", - "ssh_key", - "working_dir", - "repo_dir", - "repo_data", - "repo_exists_action", - "file_archives", - } - ), - ] - job_submission: Annotated[ - JobSubmission, - Field( - include={ - "id", - } - ), - ] - cluster_info: Annotated[Optional[ClusterInfo], Field(include=True)] = None - secrets: Annotated[Optional[Dict[str, str]], Field(include=True)] = None - repo_credentials: Annotated[Optional[RemoteRepoCreds], Field(include=True)] = None - log_quota_hour: Annotated[Optional[int], Field(include=True)] = None + run: Run + job_spec: JobSpec + job_submission: JobSubmission + cluster_info: Optional[ClusterInfo] = None + secrets: Optional[Dict[str, str]] = None + repo_credentials: Optional[RemoteRepoCreds] = None + log_quota_hour: Optional[int] = None """Maximum bytes of log output per hour. None means unlimited.""" # TODO: remove `run_spec` once instances deployed with 0.19.8 or earlier are no longer supported. - run_spec: Annotated[ - RunSpec, - Field( - include={ - "run_name", - "repo_id", - "repo_data", - "configuration", - "configuration_path", - }, - ), - ] + run_spec: RunSpec """`run_spec` is deprecated in favor of `run.run_spec`.""" + def json_for_runner(self) -> str: + """The JSON the runner is sent, restricted to `_SUBMIT_BODY_INCLUDE`.""" + return self.model_dump_json(include=_SUBMIT_BODY_INCLUDE) + class HealthcheckResponse(CoreModel): service: str diff --git a/src/dstack/_internal/server/schemas/runs.py b/src/dstack/_internal/server/schemas/runs.py index 2163320fd8..09359db304 100644 --- a/src/dstack/_internal/server/schemas/runs.py +++ b/src/dstack/_internal/server/schemas/runs.py @@ -43,7 +43,7 @@ class GetRunRequest(CoreModel): class GetRunPlanRequest(CoreModel): run_spec: RunSpec max_offers: Optional[int] = Field( - description="The maximum number of offers to return", ge=1, le=10000 + default=None, description="The maximum number of offers to return", ge=1, le=10000 ) full_offers: Annotated[ bool, Field(description="Return full offers not adjusted by requirements") diff --git a/src/dstack/_internal/server/schemas/users.py b/src/dstack/_internal/server/schemas/users.py index b95fcd9cd4..1305185d3c 100644 --- a/src/dstack/_internal/server/schemas/users.py +++ b/src/dstack/_internal/server/schemas/users.py @@ -16,7 +16,7 @@ class ListUsersRequest(CoreModel): Optional[str], Field( description="Include only users with the name containing `name_pattern`.", - regex="^[a-zA-Z0-9-_]*$", + pattern="^[a-zA-Z0-9-_]*$", ), ] = None prev_created_at: Annotated[ diff --git a/src/dstack/_internal/server/services/auth.py b/src/dstack/_internal/server/services/auth.py index 8ea40994f3..451e36842d 100644 --- a/src/dstack/_internal/server/services/auth.py +++ b/src/dstack/_internal/server/services/auth.py @@ -35,7 +35,7 @@ def list_providers() -> list[OAuthProviderInfo]: def generate_oauth_state(local_port: Optional[int] = None) -> str: value = str(secrets.token_hex(16)) state = OAuthState(value=value, local_port=local_port) - return b64encode(state.json().encode()).decode() + return b64encode(state.model_dump_json().encode()).decode() def set_state_cookie(response: Response, state: str): @@ -71,7 +71,7 @@ def get_next_redirect_url(code: str, state: str) -> Optional[str]: def _decode_state(state: str) -> Optional[OAuthState]: try: - return OAuthState.parse_raw(b64decode(state, validate=True).decode()) + return OAuthState.model_validate_json(b64decode(state, validate=True).decode()) except Exception as e: logger.debug("Exception when decoding OAuth2 state parameter: %s", repr(e)) return None diff --git a/src/dstack/_internal/server/services/backends/__init__.py b/src/dstack/_internal/server/services/backends/__init__.py index 98dcb6b2e8..0c5709f746 100644 --- a/src/dstack/_internal/server/services/backends/__init__.py +++ b/src/dstack/_internal/server/services/backends/__init__.py @@ -6,10 +6,9 @@ from uuid import UUID from cachetools import TTLCache -from pydantic import Field, ValidationError +from pydantic import ValidationError from sqlalchemy import delete, update from sqlalchemy.ext.asyncio import AsyncSession -from typing_extensions import Annotated from dstack._internal.core.backends.base.backend import Backend from dstack._internal.core.backends.base.configurator import ( @@ -23,6 +22,7 @@ from dstack._internal.core.backends.models import ( AnyBackendConfigWithCreds, AnyBackendConfigWithoutCreds, + BackendConfigWithCreds, ) from dstack._internal.core.errors import ( BackendAuthError, @@ -34,7 +34,6 @@ ServerClientError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import CoreModel from dstack._internal.core.models.instances import ( InstanceOfferWithAvailability, ) @@ -48,15 +47,11 @@ logger = get_logger(__name__) -class _BackendConfigWithCreds(CoreModel): - __root__: Annotated[AnyBackendConfigWithCreds, Field(..., discriminator="type")] - - def serialize_source_backend_config( config: AnyBackendConfigWithCreds, ) -> Tuple[str, Optional[str]]: """Split user-intent backend config into non-sensitive and sensitive JSON blobs.""" - source_config_dict = config.dict() + source_config_dict = config.model_dump() source_auth = source_config_dict.pop("creds", None) source_auth_json = None if source_auth is None else json.dumps(source_auth) return json.dumps(source_config_dict), source_auth_json @@ -218,7 +213,7 @@ def get_source_backend_config_from_backend_model( ) return None try: - return _BackendConfigWithCreds.parse_obj(source_config_dict).__root__ + return BackendConfigWithCreds.model_validate(source_config_dict).root except ValidationError: logger.warning( "Failed to validate source config for %s backend. Falling back to stored config.", diff --git a/src/dstack/_internal/server/services/compute_groups.py b/src/dstack/_internal/server/services/compute_groups.py index 4d759e0d21..8a05906b29 100644 --- a/src/dstack/_internal/server/services/compute_groups.py +++ b/src/dstack/_internal/server/services/compute_groups.py @@ -1,3 +1,4 @@ +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.compute_groups import ComputeGroup, ComputeGroupProvisioningData from dstack._internal.server.models import ComputeGroupModel @@ -17,6 +18,6 @@ def compute_group_model_to_compute_group(compute_group_model: ComputeGroupModel) def get_compute_group_provisioning_data( compute_group_model: ComputeGroupModel, ) -> ComputeGroupProvisioningData: - return ComputeGroupProvisioningData.__response__.parse_raw( - compute_group_model.provisioning_data + return validate_json_extra_ignore( + ComputeGroupProvisioningData, compute_group_model.provisioning_data ) diff --git a/src/dstack/_internal/server/services/config.py b/src/dstack/_internal/server/services/config.py index f390200695..81f6776387 100644 --- a/src/dstack/_internal/server/services/config.py +++ b/src/dstack/_internal/server/services/config.py @@ -9,6 +9,7 @@ from dstack._internal.core.backends.models import ( AnyBackendConfigWithCreds, AnyBackendFileConfigWithCreds, + BackendConfigWithCreds, BackendInfoYAML, ) from dstack._internal.core.errors import ( @@ -221,7 +222,7 @@ def _load_config(self) -> Optional[ServerConfig]: except OSError: return config_dict = yaml.safe_load(content) - return ServerConfig.parse_obj(config_dict) + return ServerConfig.model_validate(config_dict) def _save_config(self, config: ServerConfig): with open(settings.SERVER_CONFIG_FILE_PATH, "w+") as f: @@ -261,31 +262,23 @@ async def update_backend_config_yaml( await backends_services.update_backend(session=session, project=project, config=config) -class _BackendConfigWithCreds(CoreModel): - """ - Model for parsing API and file YAML configs. - """ - - __root__: Annotated[AnyBackendConfigWithCreds, Field(..., discriminator="type")] - - def config_yaml_to_backend_config(config_yaml: str) -> AnyBackendConfigWithCreds: try: config_dict = yaml.safe_load(config_yaml) except yaml.YAMLError: raise ServerClientError("Error parsing YAML") try: - backend_config = _BackendConfigWithCreds.parse_obj(config_dict).__root__ + backend_config = BackendConfigWithCreds.model_validate(config_dict).root except ValidationError as e: raise ServerClientError(str(e)) return backend_config def file_config_to_config(file_config: AnyBackendFileConfigWithCreds) -> AnyBackendConfigWithCreds: - backend_config_dict = file_config.dict() - backend_config = _BackendConfigWithCreds.parse_obj(backend_config_dict) - return backend_config.__root__ + backend_config_dict = file_config.model_dump() + backend_config = BackendConfigWithCreds.model_validate(backend_config_dict) + return backend_config.root def config_to_yaml(config: CoreModel) -> str: - return yaml.dump(config.dict(exclude_none=True), sort_keys=False) + return yaml.dump(config.model_dump(exclude_none=True), sort_keys=False) diff --git a/src/dstack/_internal/server/services/docker.py b/src/dstack/_internal/server/services/docker.py index 41580e473b..70c3c458a4 100644 --- a/src/dstack/_internal/server/services/docker.py +++ b/src/dstack/_internal/server/services/docker.py @@ -5,11 +5,11 @@ import requests from dxf import DXF from dxf.exceptions import DXFError -from pydantic import Field, ValidationError, validator +from pydantic import Field, ValidationError, field_validator from typing_extensions import Annotated from dstack._internal.core.errors import DockerRegistryError -from dstack._internal.core.models.common import CoreModel, RegistryAuth +from dstack._internal.core.models.common import CoreModel, RegistryAuth, validate_json_extra_ignore from dstack._internal.server import settings as server_settings from dstack._internal.server.utils.common import join_byte_stream_checked from dstack._internal.utils.docker import ( @@ -40,7 +40,8 @@ class ImageConfig(CoreModel): entrypoint: Annotated[Optional[List[str]], Field(alias="Entrypoint")] = None cmd: Annotated[Optional[List[str]], Field(alias="Cmd")] = None - @validator("user") + @field_validator("user") + @classmethod def normalize_user(cls, v: Optional[str]) -> Optional[str]: # If USER is not set, the corresponding field may be missing or set to an empty string if v == "": @@ -51,7 +52,8 @@ def normalize_user(cls, v: Optional[str]) -> Optional[str]: class ImageConfigObject(CoreModel): config: ImageConfig = ImageConfig() - @validator("config", pre=True) + @field_validator("config", mode="before") + @classmethod def config_set_default_if_null(cls, value): return ImageConfig() if value is None else value @@ -83,14 +85,17 @@ def get_image_config(image_name: str, registry_auth: Optional[RegistryAuth]) -> manifest_resp = registry_client.get_manifest( alias=image.digest or image.tag, platform=DEFAULT_PLATFORM ) - manifest = ImageManifest.__response__.parse_raw(manifest_resp) + assert isinstance(manifest_resp, str), ( + "get_manifest() returns the manifest JSON when `platform` is given" + ) + manifest = validate_json_extra_ignore(ImageManifest, manifest_resp) config_stream = registry_client.pull_blob(manifest.config.digest) config_resp = join_byte_stream_checked(config_stream, MAX_CONFIG_OBJECT_SIZE) # type: ignore[arg-type] if config_resp is None: raise DockerRegistryError( f"Image config object exceeds the size limit of {MAX_CONFIG_OBJECT_SIZE} bytes" ) - return ImageConfigObject.__response__.parse_raw(config_resp) + return validate_json_extra_ignore(ImageConfigObject, config_resp) except (DXFError, requests.RequestException, ValidationError) as e: raise DockerRegistryError(e) diff --git a/src/dstack/_internal/server/services/encryption/keys/aes.py b/src/dstack/_internal/server/services/encryption/keys/aes.py index 4c6e08064e..56a533bc91 100644 --- a/src/dstack/_internal/server/services/encryption/keys/aes.py +++ b/src/dstack/_internal/server/services/encryption/keys/aes.py @@ -3,7 +3,7 @@ from typing import Literal from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from pydantic import Field, validator +from pydantic import Field, field_validator from typing_extensions import Annotated from dstack._internal.core.models.common import CoreModel @@ -15,13 +15,15 @@ class AESEncryptionKeyConfig(CoreModel): name: Annotated[str, Field(description="The key name for key identification")] secret: Annotated[str, Field(description="Base64-encoded AES-256 key")] - @validator("name") + @field_validator("name") + @classmethod def validate_name(cls, v): if not v.isalnum(): raise ValueError("Key name must be alphanumeric") return v - @validator("secret") + @field_validator("secret") + @classmethod def validate_secret(cls, v): try: key = b64decode(v, validate=True) diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py index 4e93e7c2e1..2c6a924c50 100644 --- a/src/dstack/_internal/server/services/fleets.py +++ b/src/dstack/_internal/server/services/fleets.py @@ -16,7 +16,7 @@ ResourceExistsError, ServerClientError, ) -from dstack._internal.core.models.common import ApplyAction, CoreModel +from dstack._internal.core.models.common import ApplyAction, CoreModel, validate_json_extra_ignore from dstack._internal.core.models.envs import Env from dstack._internal.core.models.fleets import ( ApplyFleetPlanInput, @@ -415,7 +415,6 @@ async def get_plan( user: UserModel, spec: FleetSpec, ) -> FleetPlan: - # Spec must be copied by parsing to calculate merged_profile effective_spec = copy_model(spec) effective_spec = await apply_plugin_policies( user=user.name, @@ -907,7 +906,7 @@ def fleet_model_to_fleet( def get_fleet_spec(fleet_model: FleetModel) -> FleetSpec: - return FleetSpec.__response__.parse_raw(fleet_model.spec) + return validate_json_extra_ignore(FleetSpec, fleet_model.spec) async def generate_fleet_name(session: AsyncSession, project: ProjectModel) -> str: @@ -982,8 +981,8 @@ def get_fleet_master_instance_provisioning_data( and not instance_model.deleted and instance_model.job_provisioning_data is not None ): - return JobProvisioningData.__response__.parse_raw( - instance_model.job_provisioning_data + return validate_json_extra_ignore( + JobProvisioningData, instance_model.job_provisioning_data ) return None @@ -1043,7 +1042,7 @@ async def _create_fleet( name=spec.configuration.name, project=project, status=FleetStatus.ACTIVE, - spec=spec.json(), + spec=spec.model_dump_json(), instances=[], created_at=now, last_processed_at=now, @@ -1134,7 +1133,7 @@ async def _update_fleet( _check_can_update_fleet_spec(fleet_sensitive.spec, spec) - fleet_model.spec = spec.json() + fleet_model.spec = spec.model_dump_json() # Reset consolidation attempt so the next pipeline pass picks up the spec change promptly. fleet_model.consolidation_attempt = 0 @@ -1363,6 +1362,11 @@ def _remove_fleet_spec_sensitive_info(spec: FleetSpec): def _validate_fleet_spec_and_set_defaults(spec: FleetSpec): + # Callers do not reparse afterwards, so the defaults set here must not touch any field that + # `ProfileParams` also declares — `spec.merged_profile` is computed at parse time and would + # silently keep the pre-default value. Only `configuration.resources` is written, which + # `ProfileParams` does not declare. + # TODO: Make callers reparse if this changes. if spec.configuration.name is not None: validate_dstack_resource_name(spec.configuration.name) _validate_fleet_configuration_subtype_specific_fields(spec.configuration) @@ -1391,9 +1395,14 @@ def _validate_fleet_configuration_subtype_specific_fields(conf: FleetConfigurati subtype = "Backend" props_model = SSHFleetConfigurationProps non_default_fields: list[str] = [] - for field in props_model.__fields__.values(): - if getattr(conf, field.name) != field.default: - non_default_fields.append(field.name) + for name, field in props_model.model_fields.items(): + # `FieldInfo` has no `.name` in pydantic v2, and `.default` is `PydanticUndefined` rather + # than `None` for a required field — comparing against it directly would silently report + # every required field as non-default. No props field is required today, but that would + # arm itself the moment one is added. + default = None if field.is_required() else field.get_default(call_default_factory=True) + if getattr(conf, name) != default: + non_default_fields.append(name) if non_default_fields: raise ServerClientError( f"{subtype} fleet configuration does not support the following fields:" diff --git a/src/dstack/_internal/server/services/gateways/__init__.py b/src/dstack/_internal/server/services/gateways/__init__.py index 4cf2f30734..a59fdf3451 100644 --- a/src/dstack/_internal/server/services/gateways/__init__.py +++ b/src/dstack/_internal/server/services/gateways/__init__.py @@ -29,7 +29,11 @@ SSHError, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import ApplyAction, EntityReference +from dstack._internal.core.models.common import ( + ApplyAction, + EntityReference, + validate_json_extra_ignore, +) from dstack._internal.core.models.gateways import ( GATEWAY_REPLICAS_DEFAULT, AnyGatewayRouterConfig, @@ -216,7 +220,7 @@ def create_gateway_compute_model( gateway_id=gateway_id, backend_id=backend_id, replica_num=replica_num, - configuration=compute_configuration.json(), + configuration=compute_configuration.model_dump_json(), ssh_private_key=gateway_ssh_private_key, ssh_public_key=gateway_ssh_public_key, status=GatewayReplicaStatus.SUBMITTED, @@ -270,7 +274,7 @@ async def create_gateway( project_id=project.id, backend_id=backend_model.id, wildcard_domain=configuration.domain, - configuration=configuration.json(), + configuration=configuration.model_dump_json(), status=GatewayStatus.SUBMITTED, desired_replica_count=( configuration.replicas @@ -416,7 +420,7 @@ async def set_gateway_wildcard_domain( if gateway.configuration is not None: conf = get_gateway_configuration(gateway) conf.domain = wildcard_domain - gateway.configuration = conf.json() + gateway.configuration = conf.model_dump_json() events.emit( session, f"Gateway wildcard domain changed {old_domain!r} -> {gateway.wildcard_domain!r}", @@ -808,8 +812,8 @@ def _get_gateway_compute_router_config( ) -> Optional[AnyGatewayRouterConfig]: if compute.configuration is None: # pre-0.18.2 gateway return None # gateway routers introduced in 0.19.38 - compute_config: GatewayComputeConfiguration = ( - GatewayComputeConfiguration.__response__.parse_raw(compute.configuration) + compute_config: GatewayComputeConfiguration = validate_json_extra_ignore( + GatewayComputeConfiguration, compute.configuration ) return compute_config.router @@ -855,7 +859,7 @@ def get_gateway_compute_models(gateway_model: GatewayModel) -> List[GatewayCompu def get_gateway_configuration(gateway_model: GatewayModel) -> GatewayConfiguration: if gateway_model.configuration is not None: - return GatewayConfiguration.__response__.parse_raw(gateway_model.configuration) + return validate_json_extra_ignore(GatewayConfiguration, gateway_model.configuration) # Handle gateways created before GatewayConfiguration was introduced return GatewayConfiguration( name=gateway_model.name, @@ -871,7 +875,9 @@ def get_gateway_compute_configuration( gateway_model: GatewayModel, ) -> GatewayComputeConfiguration: if gateway_compute.configuration is not None: - return GatewayComputeConfiguration.__response__.parse_raw(gateway_compute.configuration) + return validate_json_extra_ignore( + GatewayComputeConfiguration, gateway_compute.configuration + ) # Handle gateways created before GatewayComputeConfiguration was introduced gateway_configuration = get_gateway_configuration(gateway_model) return GatewayComputeConfiguration( @@ -1079,7 +1085,7 @@ async def apply_plan( if new_configuration.replicas is not None else GATEWAY_REPLICAS_DEFAULT ) - gateway_model.configuration = new_configuration.json() + gateway_model.configuration = new_configuration.model_dump_json() gateway_model.last_update_at = get_current_datetime() events.emit( session, diff --git a/src/dstack/_internal/server/services/gateways/client.py b/src/dstack/_internal/server/services/gateways/client.py index 4abd98811c..7dabe87536 100644 --- a/src/dstack/_internal/server/services/gateways/client.py +++ b/src/dstack/_internal/server/services/gateways/client.py @@ -3,7 +3,7 @@ from typing import Optional import httpx -from pydantic import parse_obj_as +from pydantic import TypeAdapter from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT from dstack._internal.core.errors import GatewayError @@ -60,10 +60,10 @@ async def register_service( "auth": auth, "client_max_body_size": client_max_body_size, "options": options, - "rate_limits": [limit.dict() for limit in rate_limits], + "rate_limits": [limit.model_dump() for limit in rate_limits], "ssh_private_key": ssh_private_key, "has_router_replica": has_router_replica, - "router": router.dict() if router is not None else None, + "router": router.model_dump() if router is not None else None, } resp = await self._client.post( self._url(f"/api/registry/{project}/services/register"), json=payload @@ -95,7 +95,7 @@ async def register_replica( payload = { "job_id": job_submission.id.hex, "app_port": get_service_port(job_spec, run.run_spec.configuration), - "ssh_head_proxy": ssh_head_proxy.dict() if ssh_head_proxy is not None else None, + "ssh_head_proxy": ssh_head_proxy.model_dump() if ssh_head_proxy is not None else None, "ssh_head_proxy_private_key": ssh_head_proxy_private_key, } jpd = job_submission.job_provisioning_data @@ -108,7 +108,7 @@ async def register_replica( { "ssh_port": jpd.ssh_port, "ssh_host": f"{jpd.username}@{jpd.hostname}", - "ssh_proxy": jpd.ssh_proxy.dict() if jpd.ssh_proxy is not None else None, + "ssh_proxy": jpd.ssh_proxy.model_dump() if jpd.ssh_proxy is not None else None, } ) else: @@ -124,7 +124,7 @@ async def register_replica( hostname=jpd.hostname, username=jpd.username, port=jpd.ssh_port, - ).dict(), + ).model_dump(), "ssh_proxy_private_key": instance_project_ssh_private_key, } ) @@ -196,7 +196,7 @@ async def collect_stats(self) -> list[ServiceStats]: # Avoid errors if gateway is updated to new format and current server replica isn't. # TODO: remove after a few releases return [] - return parse_obj_as(list[ServiceStats], resp_data) + return TypeAdapter(list[ServiceStats]).validate_python(resp_data) def _url(self, path: str) -> str: return f"{self.base_url}/{path.lstrip('/')}" diff --git a/src/dstack/_internal/server/services/instances.py b/src/dstack/_internal/server/services/instances.py index 71cd3aac2d..129aaf4c07 100644 --- a/src/dstack/_internal/server/services/instances.py +++ b/src/dstack/_internal/server/services/instances.py @@ -16,7 +16,7 @@ from dstack._internal.core.backends.features import BACKENDS_WITH_MULTINODE_SUPPORT from dstack._internal.core.errors import ResourceNotExistsError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import EntityReference +from dstack._internal.core.models.common import EntityReference, validate_json_extra_ignore from dstack._internal.core.models.envs import Env from dstack._internal.core.models.health import HealthCheck, HealthEvent, HealthStatus from dstack._internal.core.models.instances import ( @@ -63,6 +63,7 @@ from dstack._internal.server.services.projects import list_user_project_models from dstack._internal.server.services.runner.client import ShimClient from dstack._internal.utils import common as common_utils +from dstack._internal.utils.common import get_or_error from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -303,31 +304,35 @@ def dcgm_health_response_to_health_check( def get_instance_health_response( instance_health_check_model: InstanceHealthCheckModel, ) -> InstanceHealthResponse: - return InstanceHealthResponse.__response__.parse_raw(instance_health_check_model.response) + return validate_json_extra_ignore(InstanceHealthResponse, instance_health_check_model.response) def get_instance_provisioning_data(instance_model: InstanceModel) -> Optional[JobProvisioningData]: if instance_model.job_provisioning_data is None: return None - return JobProvisioningData.__response__.parse_raw(instance_model.job_provisioning_data) + return validate_json_extra_ignore(JobProvisioningData, instance_model.job_provisioning_data) def get_instance_offer(instance_model: InstanceModel) -> Optional[InstanceOfferWithAvailability]: if instance_model.offer is None: return None - return InstanceOfferWithAvailability.__response__.parse_raw(instance_model.offer) + return validate_json_extra_ignore( + InstanceOfferWithAvailability, get_or_error(instance_model.offer) + ) def get_instance_configuration(instance_model: InstanceModel) -> InstanceConfiguration: - return InstanceConfiguration.__response__.parse_raw(instance_model.instance_configuration) + return validate_json_extra_ignore( + InstanceConfiguration, get_or_error(instance_model.instance_configuration) + ) def get_instance_profile(instance_model: InstanceModel) -> Profile: - return Profile.__response__.parse_raw(instance_model.profile) + return validate_json_extra_ignore(Profile, get_or_error(instance_model.profile)) def get_instance_requirements(instance_model: InstanceModel) -> Requirements: - return Requirements.__response__.parse_raw(instance_model.requirements) + return validate_json_extra_ignore(Requirements, get_or_error(instance_model.requirements)) def is_ssh_instance(instance_model: InstanceModel) -> bool: @@ -357,7 +362,7 @@ def get_instance_remote_connection_info( ) -> Optional[RemoteConnectionInfo]: if instance_model.remote_connection_info is None: return None - return RemoteConnectionInfo.__response__.parse_raw(instance_model.remote_connection_info) + return validate_json_extra_ignore(RemoteConnectionInfo, instance_model.remote_connection_info) def get_instance_ssh_private_keys(instance_model: InstanceModel) -> tuple[str, Optional[str]]: @@ -583,7 +588,7 @@ def instance_matches_constraints( if requirements is not None: if instance.offer is None: return False - offer = InstanceOffer.__response__.parse_raw(instance.offer) + offer = validate_json_extra_ignore(InstanceOffer, instance.offer) catalog_item = offer_to_catalog_item(offer) if not gpuhunt.matches(catalog_item, q=requirements_to_query_filter(requirements)): return False @@ -912,9 +917,9 @@ def create_instance_model( last_processed_at=now, status=InstanceStatus.PENDING, unreachable=False, - profile=profile.json(), - requirements=requirements.json(), - instance_configuration=instance_config.json(), + profile=profile.model_dump_json(), + requirements=requirements.model_dump_json(), + instance_configuration=instance_config.model_dump_json(), termination_policy=termination_policy, termination_idle_time=termination_idle_time, total_blocks=None if blocks == "auto" else blocks, @@ -987,9 +992,9 @@ async def create_ssh_instance_model( started_at=common_utils.get_current_datetime(), status=InstanceStatus.PENDING, unreachable=False, - job_provisioning_data=remote.json(), - remote_connection_info=remote_connection_info.json(), - offer=offer.json(), + job_provisioning_data=remote.model_dump_json(), + remote_connection_info=remote_connection_info.model_dump_json(), + offer=offer.model_dump_json(), region=offer.region, price=offer.price, termination_policy=TerminationPolicy.DONT_DESTROY, diff --git a/src/dstack/_internal/server/services/jobs/__init__.py b/src/dstack/_internal/server/services/jobs/__init__.py index e0c99221b3..24ceec355d 100644 --- a/src/dstack/_internal/server/services/jobs/__init__.py +++ b/src/dstack/_internal/server/services/jobs/__init__.py @@ -17,6 +17,7 @@ SSHError, ) from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.configurations import RunConfigurationType from dstack._internal.core.models.runs import ( ImagePullProgress, @@ -282,23 +283,23 @@ def job_model_to_job_submission( def get_job_provisioning_data(job_model: JobModel) -> Optional[JobProvisioningData]: if job_model.job_provisioning_data is None: return None - return JobProvisioningData.__response__.parse_raw(job_model.job_provisioning_data) + return validate_json_extra_ignore(JobProvisioningData, job_model.job_provisioning_data) def get_job_runtime_data(job_model: JobModel) -> Optional[JobRuntimeData]: if job_model.job_runtime_data is None: return None - return JobRuntimeData.__response__.parse_raw(job_model.job_runtime_data) + return validate_json_extra_ignore(JobRuntimeData, job_model.job_runtime_data) def _get_image_pull_progress(job_model: JobModel) -> Optional[ImagePullProgress]: if job_model.image_pull_progress is None: return None - return ImagePullProgress.__response__.parse_raw(job_model.image_pull_progress) + return validate_json_extra_ignore(ImagePullProgress, job_model.image_pull_progress) def get_job_spec(job_model: JobModel) -> JobSpec: - return JobSpec.__response__.parse_raw(job_model.job_spec_data) + return validate_json_extra_ignore(JobSpec, job_model.job_spec_data) def delay_job_instance_termination(job_model: JobModel): diff --git a/src/dstack/_internal/server/services/jobs/configurators/base.py b/src/dstack/_internal/server/services/jobs/configurators/base.py index c4959a41c7..5ec38790dd 100644 --- a/src/dstack/_internal/server/services/jobs/configurators/base.py +++ b/src/dstack/_internal/server/services/jobs/configurators/base.py @@ -1,3 +1,4 @@ +import json import shlex import sys import threading @@ -5,7 +6,6 @@ from pathlib import PurePosixPath from typing import Dict, List, Optional -import orjson from cachetools import TTLCache, cached from dstack._internal import settings @@ -446,7 +446,7 @@ def interpolate_job_volumes( job_volumes = [] for mount_point in run_volumes: if not isinstance(mount_point, VolumeMountPoint): - job_volumes.append(mount_point.copy()) + job_volumes.append(mount_point.model_copy()) continue if isinstance(mount_point.name, str): names = [mount_point.name] @@ -480,13 +480,13 @@ def _probe_config_to_spec(c: ProbeConfig) -> ProbeSpec: def _openai_model_probe_spec(model_name: str, prefix: str) -> ProbeSpec: - body = orjson.dumps( + body = json.dumps( { "model": model_name, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1, } - ).decode("utf-8") + ) return ProbeSpec( type="http", method="post", diff --git a/src/dstack/_internal/server/services/logs/filelog.py b/src/dstack/_internal/server/services/logs/filelog.py index e4289805c6..9f145f1d22 100644 --- a/src/dstack/_internal/server/services/logs/filelog.py +++ b/src/dstack/_internal/server/services/logs/filelog.py @@ -4,6 +4,7 @@ from uuid import UUID from dstack._internal.core.errors import ServerClientError +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.logs import ( JobSubmissionLogs, LogEvent, @@ -72,7 +73,7 @@ def _poll_logs_ascending( current_line += 1 try: - log_event = LogEvent.__response__.parse_raw(line) + log_event = validate_json_extra_ignore(LogEvent, line) except Exception: # Skip malformed lines continue @@ -109,7 +110,7 @@ def _poll_logs_descending( for line_bytes, line_start_offset in line_generator: try: line_str = line_bytes.decode("utf-8") - log_event = LogEvent.__response__.parse_raw(line_str) + log_event = validate_json_extra_ignore(LogEvent, line_str) except Exception: continue # Skip malformed lines @@ -221,7 +222,7 @@ def _write_logs(self, log_file_path: Path, log_events: List[RunnerLogEvent]) -> log_events_parsed = [self._runner_log_event_to_log_event(event) for event in log_events] log_file_path.parent.mkdir(exist_ok=True, parents=True) with open(log_file_path, "a") as f: - f.writelines(log.json() + "\n" for log in log_events_parsed) + f.writelines(log.model_dump_json() + "\n" for log in log_events_parsed) def _get_log_file_path( self, diff --git a/src/dstack/_internal/server/services/offers.py b/src/dstack/_internal/server/services/offers.py index a35f1bef93..0aaa7f3a17 100644 --- a/src/dstack/_internal/server/services/offers.py +++ b/src/dstack/_internal/server/services/offers.py @@ -185,7 +185,7 @@ def get_instance_offer_with_restricted_az( instance_offer: InstanceOfferWithAvailability, master_job_provisioning_data: Optional[JobProvisioningData], ) -> InstanceOfferWithAvailability: - instance_offer = instance_offer.copy() + instance_offer = instance_offer.model_copy() if ( master_job_provisioning_data is not None and master_job_provisioning_data.availability_zone is not None @@ -233,7 +233,7 @@ def _filter_offers( if availability_zones is not None: if offer.availability_zones is None: continue - new_offer = offer.copy() + new_offer = offer.model_copy() new_offer.availability_zones = [ z for z in offer.availability_zones if z in availability_zones ] @@ -267,6 +267,6 @@ def _get_shareable_offers( divisible, total_blocks = is_divisible_into_blocks(cpu_count, gpu_count, blocks) if not divisible: continue - new_offer = offer.copy() + new_offer = offer.model_copy() new_offer.total_blocks = total_blocks yield (backend, new_offer) diff --git a/src/dstack/_internal/server/services/placement.py b/src/dstack/_internal/server/services/placement.py index 3292b70293..6544442fff 100644 --- a/src/dstack/_internal/server/services/placement.py +++ b/src/dstack/_internal/server/services/placement.py @@ -11,6 +11,7 @@ generate_unique_placement_group_name, ) from dstack._internal.core.errors import BackendError, PlacementGroupNotSupportedError +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.instances import InstanceOffer from dstack._internal.core.models.placement import ( PlacementGroup, @@ -50,7 +51,9 @@ def placement_group_model_to_placement_group_optional( def get_placement_group_configuration( placement_group_model: PlacementGroupModel, ) -> PlacementGroupConfiguration: - return PlacementGroupConfiguration.__response__.parse_raw(placement_group_model.configuration) + return validate_json_extra_ignore( + PlacementGroupConfiguration, placement_group_model.configuration + ) def get_placement_group_provisioning_data( @@ -58,8 +61,8 @@ def get_placement_group_provisioning_data( ) -> Optional[PlacementGroupProvisioningData]: if placement_group_model.provisioning_data is None: return None - return PlacementGroupProvisioningData.__response__.parse_raw( - placement_group_model.provisioning_data + return validate_json_extra_ignore( + PlacementGroupProvisioningData, placement_group_model.provisioning_data ) @@ -170,7 +173,7 @@ async def create_placement_group( backend=master_instance_offer.backend, region=master_instance_offer.region, placement_strategy=PlacementStrategy.CLUSTER, - ).json(), + ).model_dump_json(), ) placement_group = placement_group_model_to_placement_group(placement_group_model) logger.debug( @@ -214,5 +217,5 @@ async def create_placement_group( placement_group.configuration.backend.value, placement_group.configuration.region, ) - placement_group_model.provisioning_data = pgpd.json() + placement_group_model.provisioning_data = pgpd.model_dump_json() return placement_group_model diff --git a/src/dstack/_internal/server/services/proxy/repo.py b/src/dstack/_internal/server/services/proxy/repo.py index e2a8d3c117..17c006b916 100644 --- a/src/dstack/_internal/server/services/proxy/repo.py +++ b/src/dstack/_internal/server/services/proxy/repo.py @@ -7,6 +7,7 @@ import dstack._internal.server.services.jobs as jobs_services from dstack._internal.core.consts import DSTACK_RUNNER_SSH_PORT +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.configurations import ServiceConfiguration from dstack._internal.core.models.instances import SSHConnectionParams from dstack._internal.core.models.runs import ( @@ -34,6 +35,8 @@ from dstack._internal.server.settings import DEFAULT_SERVICE_CLIENT_MAX_BODY_SIZE from dstack._internal.utils.common import get_or_error +_ANY_MODEL_ADAPTER = pydantic.TypeAdapter(AnyModel) + class ServerProxyRepo(BaseProxyRepo): """ @@ -78,8 +81,8 @@ async def get_service(self, project_name: str, run_name: str) -> Optional[Servic router = run_spec.configuration.router replicas = [] for job in jobs: - jpd: JobProvisioningData = JobProvisioningData.__response__.parse_raw( - job.job_provisioning_data + jpd: JobProvisioningData = validate_json_extra_ignore( + JobProvisioningData, get_or_error(job.job_provisioning_data) ) assert jpd.hostname is not None assert jpd.ssh_port is not None @@ -153,12 +156,14 @@ async def list_models(self, project_name: str) -> List[ChatModel]: ) models = [] for run in res.scalars().all(): - service_spec: ServiceSpec = ServiceSpec.__response__.parse_raw(run.service_spec) + service_spec: ServiceSpec = validate_json_extra_ignore( + ServiceSpec, get_or_error(run.service_spec) + ) model_spec = service_spec.model model_options_obj = service_spec.options.get("openai", {}).get("model") if model_spec is None or model_options_obj is None: continue - model_options = pydantic.parse_obj_as(AnyModel, model_options_obj) # type: ignore[arg-type] + model_options = _ANY_MODEL_ADAPTER.validate_python(model_options_obj) model = ChatModel( project_name=project_name, name=model_spec.name, diff --git a/src/dstack/_internal/server/services/repos.py b/src/dstack/_internal/server/services/repos.py index fd5bf77f38..a83c35064a 100644 --- a/src/dstack/_internal/server/services/repos.py +++ b/src/dstack/_internal/server/services/repos.py @@ -12,6 +12,7 @@ ResourceNotExistsError, ServerClientError, ) +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.repos import ( AnyRepoInfo, RepoHead, @@ -58,7 +59,7 @@ async def get_repo( if repo is None: return None if not include_creds or repo.type != RepoType.REMOTE: - return RepoHeadWithCreds.parse_obj(repo_model_to_repo_head(repo)) + return RepoHeadWithCreds.model_validate(repo_model_to_repo_head(repo).model_dump()) repo_creds = await get_repo_creds( session=session, repo=repo, @@ -130,7 +131,7 @@ async def create_repo( project_id=project.id, name=repo_id, type=RepoType(repo_info.repo_type), - info=repo_info.json(), + info=repo_info.model_dump_json(), ) try: async with session.begin_nested(): @@ -154,7 +155,7 @@ async def update_repo( RepoModel.name == repo_id, ) .values( - info=repo_info.json(), + info=repo_info.model_dump_json(), ) ) await session.commit() @@ -221,7 +222,7 @@ async def create_repo_creds( repo_creds = RepoCredsModel( repo_id=repo.id, user_id=user.id, - creds=DecryptedString(plaintext=creds.json()), + creds=DecryptedString(plaintext=creds.model_dump_json()), ) try: async with session.begin_nested(): @@ -245,7 +246,7 @@ async def update_repo_creds( RepoCredsModel.user_id == user.id, ) .values( - creds=DecryptedString(plaintext=creds.json()), + creds=DecryptedString(plaintext=creds.model_dump_json()), ) ) await session.commit() @@ -343,11 +344,12 @@ async def get_code_model( def repo_model_to_repo_head(repo_model: RepoModel) -> RepoHead: - return RepoHead.__response__.parse_obj( + return validate_extra_ignore( + RepoHead, { "repo_id": repo_model.name, "repo_info": json.loads(repo_model.info), - } + }, ) @@ -359,10 +361,11 @@ def repo_model_to_repo_head_with_creds( repo_creds_raw = repo_model.creds else: repo_creds_raw = repo_creds_model.creds.plaintext - return RepoHeadWithCreds.__response__.parse_obj( + return validate_extra_ignore( + RepoHeadWithCreds, { "repo_id": repo_model.name, "repo_info": json.loads(repo_model.info), "repo_creds": json.loads(repo_creds_raw) if repo_creds_raw else None, - } + }, ) diff --git a/src/dstack/_internal/server/services/requirements/combine.py b/src/dstack/_internal/server/services/requirements/combine.py index 53cec3457e..4ad168586e 100644 --- a/src/dstack/_internal/server/services/requirements/combine.py +++ b/src/dstack/_internal/server/services/requirements/combine.py @@ -86,7 +86,7 @@ def _combine_backend_options( if opt.type in by_type: by_type[opt.type] = by_type[opt.type].combine(opt) else: - by_type[opt.type] = opt.copy(deep=True) + by_type[opt.type] = opt.model_copy(deep=True) return list(by_type.values()) @@ -251,10 +251,10 @@ def _combine_models_optional( ) -> Optional[_ModelT]: if value1 is None: if value2 is not None: - return value2.copy(deep=True) + return value2.model_copy(deep=True) return None if value2 is None: - return value1.copy(deep=True) + return value1.model_copy(deep=True) return combiner(value1, value2) @@ -279,8 +279,8 @@ def _combine_copy_model_list_optional( ) -> Optional[List[_ModelT]]: if value1 is None: if value2 is not None: - return [item.copy(deep=True) for item in value2] + return [item.model_copy(deep=True) for item in value2] return None if value2 is None: - return [item.copy(deep=True) for item in value1] + return [item.model_copy(deep=True) for item in value1] return combiner(value1, value2) diff --git a/src/dstack/_internal/server/services/resources.py b/src/dstack/_internal/server/services/resources.py index 8b38f92f4e..176445d465 100644 --- a/src/dstack/_internal/server/services/resources.py +++ b/src/dstack/_internal/server/services/resources.py @@ -1,14 +1,13 @@ from typing import Optional import gpuhunt -from pydantic import parse_obj_as from dstack._internal.core.models.resources import CPUSpec, ResourcesSpec def set_resources_defaults(resources: ResourcesSpec) -> None: # TODO: Remove in 0.20. Use resources.cpu directly - cpu = parse_obj_as(CPUSpec, resources.cpu) + cpu = CPUSpec.model_validate(resources.cpu) if cpu.arch is None: gpu = resources.gpu if ( diff --git a/src/dstack/_internal/server/services/runner/client.py b/src/dstack/_internal/server/services/runner/client.py index 1ba5e556ee..695afbca72 100644 --- a/src/dstack/_internal/server/services/runner/client.py +++ b/src/dstack/_internal/server/services/runner/client.py @@ -13,7 +13,7 @@ from dstack._internal.core.consts import DSTACK_PROJECT_ENV from dstack._internal.core.errors import DstackError -from dstack._internal.core.models.common import CoreModel, NetworkMode +from dstack._internal.core.models.common import CoreModel, NetworkMode, validate_extra_ignore from dstack._internal.core.models.envs import Env from dstack._internal.core.models.instances import GpuDriverInfo from dstack._internal.core.models.repos.remote import RemoteRepoCreds @@ -112,7 +112,7 @@ def get_metrics(self) -> Optional[MetricsResponse]: if resp.status_code == 404: return None resp.raise_for_status() - return MetricsResponse.__response__.parse_obj(resp.json()) + return validate_extra_ignore(MetricsResponse, resp.json()) def submit_job( self, @@ -142,7 +142,7 @@ def submit_job( merged_env.update(router_env) if server_access: merged_env.setdefault(DSTACK_PROJECT_ENV, run.project_name) - job_spec = job_spec.copy(deep=True) + job_spec = job_spec.model_copy(deep=True) job_spec.env = merged_env quota = server_settings.SERVER_LOG_QUOTA_PER_JOB_HOUR body = SubmitBody( @@ -156,9 +156,8 @@ def submit_job( run_spec=run.run_spec, ) resp = self._session.post( - # use .json() to encode enums self._url("/api/submit"), - data=body.json(), + data=body.json_for_runner(), headers={"Content-Type": "application/json"}, timeout=REQUEST_TIMEOUT, ) @@ -184,14 +183,14 @@ def run_job(self) -> Optional[JobInfoResponse]: if not _is_json_response(resp): # Old runner or runner failed to get job info return None - return JobInfoResponse.__response__.parse_obj(resp.json()) + return validate_extra_ignore(JobInfoResponse, resp.json()) def pull(self, timestamp: int) -> PullResponse: resp = self._session.get( self._url("/api/pull"), params={"timestamp": timestamp}, timeout=REQUEST_TIMEOUT ) resp.raise_for_status() - return PullResponse.__response__.parse_obj(resp.json()) + return validate_extra_ignore(PullResponse, resp.json()) def stop(self): resp = self._session.post(self._url("/api/stop"), timeout=REQUEST_TIMEOUT) @@ -203,7 +202,7 @@ def _url(self, path: str) -> str: def _healthcheck(self) -> HealthcheckResponse: resp = self._session.get(self._url("/api/healthcheck"), timeout=REQUEST_TIMEOUT) resp.raise_for_status() - return HealthcheckResponse.__response__.parse_obj(resp.json()) + return validate_extra_ignore(HealthcheckResponse, resp.json()) def _negotiate(self, healthcheck_response: Optional[HealthcheckResponse] = None) -> None: if healthcheck_response is None: @@ -638,7 +637,7 @@ def _request( ) -> requests.Response: url = f"{self._base_url}/{path.lstrip('/')}" if body is not None: - json = body.dict() + json = body.model_dump() else: json = None resp = self._session.request(method, url, json=json, timeout=REQUEST_TIMEOUT) @@ -649,7 +648,7 @@ def _request( _M = TypeVar("_M", bound=CoreModel) def _response(self, model_cls: type[_M], response: requests.Response) -> _M: - return model_cls.__response__.parse_obj(response.json()) + return validate_extra_ignore(model_cls, response.json()) def _raise_for_status(self, response: requests.Response) -> None: try: @@ -728,7 +727,7 @@ def instance_info_response_to_gpu_driver( ) -> Optional[GpuDriverInfo]: if response is None or not response.gpu_driver_version: return None - return GpuDriverInfo.parse_obj( + return GpuDriverInfo.model_validate( {"vendor": response.gpu_vendor, "version": response.gpu_driver_version} ) diff --git a/src/dstack/_internal/server/services/runs/__init__.py b/src/dstack/_internal/server/services/runs/__init__.py index b7668f7d20..02b72c981f 100644 --- a/src/dstack/_internal/server/services/runs/__init__.py +++ b/src/dstack/_internal/server/services/runs/__init__.py @@ -18,7 +18,7 @@ ResourceNotExistsError, ServerClientError, ) -from dstack._internal.core.models.common import ApplyAction +from dstack._internal.core.models.common import ApplyAction, validate_json_extra_ignore from dstack._internal.core.models.profiles import ( RetryEvent, ) @@ -153,7 +153,7 @@ def get_run_status_change_message( def get_run_spec(run_model: RunModel) -> RunSpec: - return RunSpec.__response__.parse_raw(run_model.run_spec) + return validate_json_extra_ignore(RunSpec, run_model.run_spec) async def list_user_runs( @@ -536,14 +536,14 @@ async def get_plan( unallocated_resources: bool, legacy_repo_dir: bool = False, ) -> RunPlan: - # Spec must be copied by parsing to calculate merged_profile - effective_run_spec = RunSpec.parse_obj(run_spec.dict()) + effective_run_spec = RunSpec.model_validate(run_spec.model_dump()) effective_run_spec = await apply_plugin_policies( user=user.name, project=project.name, spec=effective_run_spec, ) - effective_run_spec = RunSpec.parse_obj(effective_run_spec.dict()) + # Spec must be copied by parsing to calculate merged_profile + effective_run_spec = RunSpec.model_validate(effective_run_spec.model_dump()) validate_run_spec_and_set_defaults( user=user, run_spec=effective_run_spec, @@ -603,7 +603,7 @@ async def apply_plan( spec=run_spec, ) # Spec must be copied by parsing to calculate merged_profile - run_spec = RunSpec.parse_obj(run_spec.dict()) + run_spec = RunSpec.model_validate(run_spec.model_dump()) validate_run_spec_and_set_defaults( user=user, run_spec=run_spec, legacy_repo_dir=legacy_repo_dir ) @@ -658,7 +658,7 @@ async def apply_plan( update(RunModel) .where(RunModel.id == current_resource.id) .values( - run_spec=run_spec.json(), + run_spec=run_spec.model_dump_json(), priority=run_spec.configuration.priority, deployment_num=new_deployment_num, ) @@ -743,7 +743,7 @@ async def submit_run( run_name=run_spec.run_name, submitted_at=submitted_at, status=initial_status, - run_spec=run_spec.json(), + run_spec=run_spec.model_dump_json(), last_processed_at=submitted_at, priority=run_spec.configuration.priority, deployment_num=0, @@ -859,7 +859,7 @@ def create_job_model_for_new_submission( last_processed_at=now, status=status, termination_reason=None, - job_spec_data=job.job_spec.json(), + job_spec_data=job.job_spec.model_dump_json(), job_provisioning_data=None, probes=[], waiting_master_job=job.job_spec.job_num != 0, @@ -982,7 +982,7 @@ def run_model_to_run( service_spec = None if run_model.service_spec is not None: - service_spec = ServiceSpec.__response__.parse_raw(run_model.service_spec) + service_spec = validate_json_extra_ignore(ServiceSpec, run_model.service_spec) status_message = _get_run_status_message(run_model, job_models=job_models) error = _get_run_error(run_model) diff --git a/src/dstack/_internal/server/services/runs/plan.py b/src/dstack/_internal/server/services/runs/plan.py index 97d3185088..5da7a7fe77 100644 --- a/src/dstack/_internal/server/services/runs/plan.py +++ b/src/dstack/_internal/server/services/runs/plan.py @@ -968,7 +968,7 @@ def _get_backend_offer_identity(offer: InstanceOfferWithAvailability) -> Hashabl Needed to deduplicate identical backend offers when merging offers from multiple fleets for `dstack offer --fleet ...`. """ - return _freeze_offer_identity_value(offer.dict()) + return _freeze_offer_identity_value(offer.model_dump()) def _freeze_offer_identity_value(value: object) -> Hashable: diff --git a/src/dstack/_internal/server/services/runs/router_worker_sync.py b/src/dstack/_internal/server/services/runs/router_worker_sync.py index c9960f54d9..4b9b8af656 100644 --- a/src/dstack/_internal/server/services/runs/router_worker_sync.py +++ b/src/dstack/_internal/server/services/runs/router_worker_sync.py @@ -23,6 +23,7 @@ from typing_extensions import NotRequired from dstack._internal.core.errors import SSHError +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.configurations import ReplicaGroup, ServiceConfiguration from dstack._internal.core.models.runs import JobStatus, RunSpec, get_service_port from dstack._internal.server.models import JobModel, RunModel @@ -125,7 +126,7 @@ class _WorkerPayloadResult(TypedDict): def run_model_has_sglang_router_replica_group(run_model: RunModel) -> bool: - run_spec = RunSpec.__response__.parse_raw(run_model.run_spec) + run_spec = validate_json_extra_ignore(RunSpec, run_model.run_spec) return run_spec_has_sglang_router_replica_group(run_spec) @@ -626,7 +627,7 @@ async def _build_target_workers( async def sync_router_workers_for_run_model(run_model: RunModel) -> None: - run_spec = RunSpec.__response__.parse_raw(run_model.run_spec) + run_spec = validate_json_extra_ignore(RunSpec, run_model.run_spec) config = run_spec.configuration if not isinstance(config, ServiceConfiguration): return diff --git a/src/dstack/_internal/server/services/runs/spec.py b/src/dstack/_internal/server/services/runs/spec.py index cb989ef5b4..364f81769c 100644 --- a/src/dstack/_internal/server/services/runs/spec.py +++ b/src/dstack/_internal/server/services/runs/spec.py @@ -73,6 +73,12 @@ def validate_run_spec_and_set_defaults( # If a property is stored in job_spec - resolve the default there. # Server defaults are preferable over client defaults so that # the defaults depend on the server version, not the client version. + # + # Callers do not reparse afterwards, so the defaults set here must not touch any field that + # `ProfileParams` also declares — `run_spec.merged_profile` is computed at parse time and would + # silently keep the pre-default value. The fields written here (`repo_id`, `repo_data`, + # `ssh_key_pub`, `configuration.priority`, `configuration.resources`, + # `configuration.working_dir`) are none of them declared by `ProfileParams`. if run_spec.run_name is not None: validate_dstack_resource_name(run_spec.run_name) _validate_retry_duration(run_spec) diff --git a/src/dstack/_internal/server/services/services/__init__.py b/src/dstack/_internal/server/services/services/__init__.py index b637683af7..df3985b674 100644 --- a/src/dstack/_internal/server/services/services/__init__.py +++ b/src/dstack/_internal/server/services/services/__init__.py @@ -93,7 +93,7 @@ async def register_service(session: AsyncSession, run_model: RunModel, run_spec: "This dstack-server installation forbids services without a gateway." " Please configure a gateway." ) - run_model.service_spec = service_spec.json() + run_model.service_spec = service_spec.model_dump_json() async def _register_service_in_gateway( diff --git a/src/dstack/_internal/server/services/services/options.py b/src/dstack/_internal/server/services/services/options.py index 3e26be39ba..40b45e797f 100644 --- a/src/dstack/_internal/server/services/services/options.py +++ b/src/dstack/_internal/server/services/services/options.py @@ -49,5 +49,5 @@ def get_service_options(conf: ServiceConfiguration) -> dict: options = {} if conf.model is not None: complete_service_model(conf.model, env=conf.env.as_dict()) - options["openai"] = {"model": conf.model.dict()} + options["openai"] = {"model": conf.model.model_dump()} return options diff --git a/src/dstack/_internal/server/services/ssh_fleets/provisioning.py b/src/dstack/_internal/server/services/ssh_fleets/provisioning.py index 553b82f852..ed48b619e6 100644 --- a/src/dstack/_internal/server/services/ssh_fleets/provisioning.py +++ b/src/dstack/_internal/server/services/ssh_fleets/provisioning.py @@ -291,10 +291,10 @@ def host_info_to_instance_type(host_info: Dict[str, Any], arch: GoArchType) -> I resources=Resources( cpu_arch=arch.to_cpu_architecture(), cpus=host_info["cpus"], - memory_mib=host_info["memory"] / 1024 / 1024, + memory_mib=host_info["memory"] // 1024 // 1024, spot=False, gpus=gpus, - disk=Disk(size_mib=host_info["disk_size"] / 1024 / 1024), + disk=Disk(size_mib=host_info["disk_size"] // 1024 // 1024), ), ) return instance_type diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py index 1ac1f357c4..752f45a49e 100644 --- a/src/dstack/_internal/server/services/templates.py +++ b/src/dstack/_internal/server/services/templates.py @@ -91,7 +91,7 @@ def _parse_templates(repo_path: Path) -> List[UITemplate]: if data.get("type") != "template": logger.debug("Skipping %s: type is not 'template'", entry.name) continue - template = UITemplate.parse_obj(data) + template = UITemplate.model_validate(data) templates.append(template) except Exception: logger.warning("Skipping invalid template %s", entry.name, exc_info=True) diff --git a/src/dstack/_internal/server/services/volumes.py b/src/dstack/_internal/server/services/volumes.py index 9ec85ad8d2..374fe76a4c 100644 --- a/src/dstack/_internal/server/services/volumes.py +++ b/src/dstack/_internal/server/services/volumes.py @@ -14,7 +14,8 @@ ResourceExistsError, ServerClientError, ) -from dstack._internal.core.models.profiles import parse_duration +from dstack._internal.core.models.common import validate_json_extra_ignore +from dstack._internal.core.models.duration import parse_duration from dstack._internal.core.models.volumes import ( AnyVolumeConfiguration, Volume, @@ -300,7 +301,7 @@ async def create_volume( user_id=user.id, project=project, status=VolumeStatus.SUBMITTED, - configuration=configuration.json(), + configuration=configuration.model_dump_json(), auto_cleanup_enabled=_get_autocleanup_enabled(configuration), attachments=[], created_at=now, @@ -418,19 +419,21 @@ def volume_model_to_volume(volume_model: VolumeModel) -> Volume: def get_volume_configuration(volume_model: VolumeModel) -> AnyVolumeConfiguration: - return VolumeConfiguration.__response__.parse_raw(volume_model.configuration).__root__ + return validate_json_extra_ignore(VolumeConfiguration, volume_model.configuration).root def get_volume_provisioning_data(volume_model: VolumeModel) -> Optional[VolumeProvisioningData]: if volume_model.volume_provisioning_data is None: return None - return VolumeProvisioningData.__response__.parse_raw(volume_model.volume_provisioning_data) + return validate_json_extra_ignore( + VolumeProvisioningData, volume_model.volume_provisioning_data + ) def get_volume_attachment_data(volume_model: VolumeModel) -> Optional[VolumeAttachmentData]: if volume_model.volume_attachment_data is None: return None - return VolumeAttachmentData.__response__.parse_raw(volume_model.volume_attachment_data) + return validate_json_extra_ignore(VolumeAttachmentData, volume_model.volume_attachment_data) def get_attachment_data( @@ -438,7 +441,9 @@ def get_attachment_data( ) -> Optional[VolumeAttachmentData]: if volume_attachment_model.attachment_data is None: return None - return VolumeAttachmentData.__response__.parse_raw(volume_attachment_model.attachment_data) + return validate_json_extra_ignore( + VolumeAttachmentData, volume_attachment_model.attachment_data + ) def instance_model_to_volume_instance(instance_model: InstanceModel) -> VolumeInstance: diff --git a/src/dstack/_internal/server/testing/common.py b/src/dstack/_internal/server/testing/common.py index 9009eec6c4..46b51a189e 100644 --- a/src/dstack/_internal/server/testing/common.py +++ b/src/dstack/_internal/server/testing/common.py @@ -26,7 +26,7 @@ ComputeWithVolumeSupport, ) from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import NetworkMode +from dstack._internal.core.models.common import NetworkMode, validate_json_extra_ignore from dstack._internal.core.models.compute_groups import ( ComputeGroupProvisioningData, ComputeGroupStatus, @@ -35,6 +35,7 @@ AnyRunConfiguration, DevEnvironmentConfiguration, ) +from dstack._internal.core.models.duration import OptionalIdleDuration from dstack._internal.core.models.envs import Env from dstack._internal.core.models.fleets import ( FleetConfiguration, @@ -408,7 +409,7 @@ async def create_run( run_name=run_name, status=status, termination_reason=termination_reason, - run_spec=run_spec.json(), + run_spec=run_spec.model_dump_json(), last_processed_at=last_processed_at, jobs=[], priority=priority, @@ -445,7 +446,7 @@ async def create_job( ) -> JobModel: if deployment_num is None: deployment_num = run.deployment_num - run_spec = RunSpec.__response__.parse_raw(run.run_spec) + run_spec = validate_json_extra_ignore(RunSpec, run.run_spec) job_spec = ( await get_job_specs_from_run_spec(run_spec=run_spec, secrets={}, replica_num=replica_num) )[0] @@ -464,9 +465,11 @@ async def create_job( last_processed_at=last_processed_at, status=status, termination_reason=termination_reason, - job_spec_data=job_spec.json(), - job_provisioning_data=job_provisioning_data.json() if job_provisioning_data else None, - job_runtime_data=job_runtime_data.json() if job_runtime_data else None, + job_spec_data=job_spec.model_dump_json(), + job_provisioning_data=job_provisioning_data.model_dump_json() + if job_provisioning_data + else None, + job_runtime_data=job_runtime_data.model_dump_json() if job_runtime_data else None, instance=instance, instance_assigned=instance_assigned, used_instance_id=instance.id if instance is not None else None, @@ -588,7 +591,7 @@ async def create_compute_group( project=project, fleet=fleet, status=status, - provisioning_data=provisioning_data.json(), + provisioning_data=provisioning_data.model_dump_json(), last_processed_at=last_processed_at, ) session.add(compute_group) @@ -673,7 +676,7 @@ async def create_gateway( domain=wildcard_domain, replicas=replicas, certificate=certificate, - ).json() + ).model_dump_json() gateway = GatewayModel( project_id=project_id, backend_id=backend_id, @@ -732,7 +735,7 @@ async def create_gateway_compute( public_ip=True, ssh_key_pub=ssh_public_key, certificate=None, - ).json() + ).model_dump_json() gateway_compute = GatewayComputeModel( gateway_id=gateway_id, backend_id=backend_id, @@ -796,7 +799,7 @@ async def create_fleet( name=spec.configuration.name, status=status, created_at=created_at, - spec=spec.json(), + spec=spec.model_dump_json(), instances=[], runs=[], last_processed_at=last_processed_at, @@ -942,17 +945,21 @@ async def create_instance( created_at=created_at, started_at=created_at, finished_at=finished_at, - job_provisioning_data=job_provisioning_data.json() if job_provisioning_data else None, - offer=offer.json() if offer else None, + job_provisioning_data=job_provisioning_data.model_dump_json() + if job_provisioning_data + else None, + offer=offer.model_dump_json() if offer else None, price=price, region=region, backend=backend, termination_policy=termination_policy, termination_idle_time=termination_idle_time, - profile=profile.json(), - requirements=requirements.json(), - instance_configuration=instance_configuration.json(), - remote_connection_info=remote_connection_info.json() if remote_connection_info else None, + profile=profile.model_dump_json(), + requirements=requirements.model_dump_json(), + instance_configuration=instance_configuration.model_dump_json(), + remote_connection_info=remote_connection_info.model_dump_json() + if remote_connection_info + else None, volume_attachments=volume_attachments, total_blocks=total_blocks, busy_blocks=busy_blocks, @@ -1038,7 +1045,7 @@ def get_remote_connection_info( if env is None: env = Env() elif isinstance(env, dict): - env = Env.parse_obj(env) + env = Env.model_validate(env) return RemoteConnectionInfo( host=host, port=port, @@ -1109,8 +1116,8 @@ async def create_volume( created_at=created_at, last_processed_at=last_processed_at, last_job_processed_at=last_job_processed_at, - configuration=configuration.json(), - volume_provisioning_data=volume_provisioning_data.json() + configuration=configuration.model_dump_json(), + volume_provisioning_data=volume_provisioning_data.model_dump_json() if volume_provisioning_data else None, attachments=[], @@ -1170,10 +1177,10 @@ def get_volume_configuration( region: str = "eu-west-1", size: Optional[Memory] = Memory(100), volume_id: Optional[str] = None, - auto_cleanup_duration: Optional[Union[str, int]] = None, + auto_cleanup_duration: OptionalIdleDuration = None, ) -> AnyVolumeConfiguration: assert backend != BackendType.KUBERNETES, "use get_kubernetes_volume_configuration() instead" - return VolumeConfiguration.parse_obj( + return VolumeConfiguration.model_validate( dict( name=name, backend=backend, @@ -1182,14 +1189,14 @@ def get_volume_configuration( volume_id=volume_id, auto_cleanup_duration=auto_cleanup_duration, ) - ).__root__ + ).root def get_kubernetes_volume_configuration( name: str = "test-volume", size: Optional[Memory] = Memory(100), claim_name: Optional[str] = None, - auto_cleanup_duration: Optional[Union[str, int]] = None, + auto_cleanup_duration: OptionalIdleDuration = None, storage_class_name: Optional[str] = None, ) -> KubernetesVolumeConfiguration: return KubernetesVolumeConfiguration( @@ -1241,8 +1248,8 @@ async def create_placement_group( fleet=fleet, name=name, created_at=created_at, - configuration=configuration.json(), - provisioning_data=provisioning_data.json(), + configuration=configuration.model_dump_json(), + provisioning_data=provisioning_data.model_dump_json(), fleet_deleted=fleet_deleted, deleted=deleted, deleted_at=deleted_at, diff --git a/src/dstack/_internal/server/utils/routers.py b/src/dstack/_internal/server/utils/routers.py index e56dcb7be8..4d2eb38a35 100644 --- a/src/dstack/_internal/server/utils/routers.py +++ b/src/dstack/_internal/server/utils/routers.py @@ -1,15 +1,17 @@ from typing import Any, Dict, List, Optional -import orjson import packaging.version from fastapi import HTTPException, Request, Response, status from fastapi.staticfiles import StaticFiles +from pydantic_core import to_json from dstack._internal.core.errors import ServerClientError, ServerClientErrorCode from dstack._internal.core.models.common import CoreModel -from dstack._internal.utils.json_utils import get_orjson_default_options, orjson_default +from dstack._internal.utils.logging import get_logger from dstack._internal.utils.version import parse_version +logger = get_logger(__name__) + class CustomStaticFiles(StaticFiles): """ @@ -27,26 +29,41 @@ async def __call__(self, scope, receive, send) -> None: await super().__call__(scope, receive, send) -class CustomORJSONResponse(Response): +class CustomJSONResponse(Response): """ - Custom JSONResponse that uses orjson for serialization. + JSONResponse backed by pydantic's own Rust serializer. It's recommended to return this class from routers directly instead of returning pydantic models to avoid the FastAPI's jsonable_encoder overhead. See https://fastapi.tiangolo.com/advanced/custom-response/#use-orjsonresponse. Beware that FastAPI skips model validation when responses are returned directly. - If serialization needs to be modified, override `dict()` instead of adding validators. + If serialization needs to be modified, add a `@field_serializer`/`@model_serializer` + instead of adding validators. """ media_type = "application/json" def render(self, content: Any) -> bytes: - return orjson.dumps( - content, - option=get_orjson_default_options(), - default=orjson_default, - ) + # `content` is a model, a list of models, or a plain dict (the `server/compatibility/` + # patches mutate models in place, but some routers do assemble dicts), so it has to be + # serialized generically rather than through one model's `model_dump_json`. + return to_json(content, fallback=_fallback) + + +def _fallback(obj: Any) -> str: + """ + Last resort for a type `to_json` cannot serialize. + + Returning a string keeps one unexpected value from turning the whole response into a 500, but + it also puts a `repr` on the wire where the client expects real data, so it must not pass + silently: the fix is a `@field_serializer` on the field that produced it. + """ + logger.error( + "Response contains a value of non-serializable type %s. Add a serializer for it.", + type(obj).__name__, + ) + return str(obj) class BadRequestDetailsModel(CoreModel): diff --git a/src/dstack/_internal/utils/common.py b/src/dstack/_internal/utils/common.py index 9c33d2447e..a6c3828a60 100644 --- a/src/dstack/_internal/utils/common.py +++ b/src/dstack/_internal/utils/common.py @@ -13,9 +13,10 @@ from urllib.parse import urlparse from uuid import UUID +from pydantic import TypeAdapter from typing_extensions import ParamSpec -from dstack._internal.core.models.common import Duration +from dstack._internal.core.models.duration import Duration from dstack._internal.utils.interpolator import InterpolatorError, VariablesInterpolator @@ -67,6 +68,23 @@ def get_milliseconds_since_epoch() -> int: return int(round(time.time() * 1000)) +_DATETIME_ADAPTER = TypeAdapter(datetime) + + +def render_datetime_as_api(value: datetime) -> str: + """ + Render a datetime the way the API serializes one. + + Delegates to pydantic rather than post-processing `isoformat()`, so the result cannot drift + from what the models emit. The two differ: pydantic v2 spells a zero UTC offset `Z`, while + `isoformat()` spells it `+00:00`. + + Only needed where a datetime is formatted by hand. Anything handed to pydantic or to + `pydantic_core.to_json` is already rendered this way. + """ + return _DATETIME_ADAPTER.dump_python(value, mode="json") + + DateFormatter = Callable[[datetime], str] diff --git a/src/dstack/_internal/utils/json_schema.py b/src/dstack/_internal/utils/json_schema.py deleted file mode 100644 index aec695c16d..0000000000 --- a/src/dstack/_internal/utils/json_schema.py +++ /dev/null @@ -1,16 +0,0 @@ -def add_extra_schema_types(schema_property: dict, extra_types: list[dict]): - if "allOf" in schema_property: - refs = [schema_property.pop("allOf")[0]] - elif "anyOf" in schema_property: - refs = schema_property.pop("anyOf") - elif "oneOf" in schema_property: - nested = {"oneOf": schema_property.pop("oneOf")} - if "discriminator" in schema_property: - nested["discriminator"] = schema_property.pop("discriminator") - refs = [nested] - elif "type" in schema_property: - refs = [{"type": schema_property.pop("type")}] - else: - refs = [{"$ref": schema_property.pop("$ref")}] - refs.extend(extra_types) - schema_property["anyOf"] = refs diff --git a/src/dstack/_internal/utils/json_utils.py b/src/dstack/_internal/utils/json_utils.py deleted file mode 100644 index 9017e94c31..0000000000 --- a/src/dstack/_internal/utils/json_utils.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Any - -import orjson -from pydantic import BaseModel - -FREEZEGUN = True -try: - from freezegun.api import FakeDatetime -except ImportError: - FREEZEGUN = False - - -ASYNCPG = True -try: - import asyncpg.pgproto.pgproto -except ImportError: - ASYNCPG = False - - -def pydantic_orjson_dumps(v: Any, *, default: Any) -> str: - return orjson.dumps( - v, - option=get_orjson_default_options(), - default=orjson_default, - ).decode() - - -def pydantic_orjson_dumps_with_indent(v: Any, *, default: Any) -> str: - return orjson.dumps( - v, - option=get_orjson_default_options() | orjson.OPT_INDENT_2, - default=orjson_default, - ).decode() - - -def orjson_default(obj): - if isinstance(obj, float): - # orjson does not convert float subclasses be default - return float(obj) - if isinstance(obj, BaseModel): - # Allows calling orjson.dumps() on pydantic models - # (e.g. to return from the API) - return obj.dict() - if ASYNCPG: - if isinstance(obj, asyncpg.pgproto.pgproto.UUID): - return str(obj) - if FREEZEGUN: - if isinstance(obj, FakeDatetime): - return obj.isoformat() - raise TypeError - - -def get_orjson_default_options() -> int: - return orjson.OPT_NON_STR_KEYS diff --git a/src/dstack/api/server/__init__.py b/src/dstack/api/server/__init__.py index d1130713d6..8a2f2a5125 100644 --- a/src/dstack/api/server/__init__.py +++ b/src/dstack/api/server/__init__.py @@ -2,7 +2,7 @@ import os import pprint import time -from typing import Dict, List, Optional, Type +from typing import Dict, List, Optional, Type, Union import requests import requests_unixsocket @@ -156,7 +156,7 @@ def get_token_hash(self) -> str: def _request( self, path: str, - body: Optional[str] = None, + body: Optional[Union[str, bytes]] = None, raise_for_status: bool = True, method: str = "POST", **kwargs, diff --git a/src/dstack/api/server/_auth.py b/src/dstack/api/server/_auth.py index b944a292a2..e68c38ef04 100644 --- a/src/dstack/api/server/_auth.py +++ b/src/dstack/api/server/_auth.py @@ -1,8 +1,7 @@ from typing import Optional -from pydantic import parse_obj_as - from dstack._internal.core.models.auth import OAuthProviderInfo +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.users import UserWithCreds from dstack._internal.server.schemas.auth import ( OAuthAuthorizeRequest, @@ -15,16 +14,16 @@ class AuthAPIClient(APIClientGroup): def list_providers(self) -> list[OAuthProviderInfo]: resp = self._request("/api/auth/list_providers") - return parse_obj_as(list[OAuthProviderInfo.__response__], resp.json()) + return validate_extra_ignore(list[OAuthProviderInfo], resp.json()) def authorize(self, provider: str, local_port: Optional[int] = None) -> OAuthAuthorizeResponse: body = OAuthAuthorizeRequest(local_port=local_port) - resp = self._request(f"/api/auth/{provider}/authorize", body=body.json()) - return parse_obj_as(OAuthAuthorizeResponse.__response__, resp.json()) + resp = self._request(f"/api/auth/{provider}/authorize", body=body.model_dump_json()) + return validate_extra_ignore(OAuthAuthorizeResponse, resp.json()) def callback( self, provider: str, code: str, state: str, base_url: Optional[str] = None ) -> UserWithCreds: body = OAuthCallbackRequest(code=code, state=state, base_url=base_url) - resp = self._request(f"/api/auth/{provider}/callback", body=body.json()) - return parse_obj_as(UserWithCreds.__response__, resp.json()) + resp = self._request(f"/api/auth/{provider}/callback", body=body.model_dump_json()) + return validate_extra_ignore(UserWithCreds, resp.json()) diff --git a/src/dstack/api/server/_backends.py b/src/dstack/api/server/_backends.py index 08e5cd597d..4e742f6f7f 100644 --- a/src/dstack/api/server/_backends.py +++ b/src/dstack/api/server/_backends.py @@ -1,12 +1,13 @@ from typing import List -from pydantic import parse_obj_as +from pydantic import TypeAdapter from dstack._internal.core.backends.models import ( AnyBackendConfigWithCreds, - AnyBackendConfigWithCredsResponse, + AnyBackendConfigWithCredsTagged, ) from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.server.schemas.backends import DeleteBackendsRequest from dstack.api.server._group import APIClientGroup @@ -15,7 +16,7 @@ class BackendsAPIClient(APIClientGroup): def list_backend_types(self) -> List[BackendType]: resp = self._request("/api/backends/list_types") backend_types = [] - for value in parse_obj_as(List[str], resp.json()): + for value in TypeAdapter(List[str]).validate_python(resp.json()): try: backend_types.append(BackendType(value)) except ValueError: @@ -25,21 +26,25 @@ def list_backend_types(self) -> List[BackendType]: def create( self, project_name: str, config: AnyBackendConfigWithCreds ) -> AnyBackendConfigWithCreds: - resp = self._request(f"/api/project/{project_name}/backends/create", body=config.json()) - return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) + resp = self._request( + f"/api/project/{project_name}/backends/create", body=config.model_dump_json() + ) + return validate_extra_ignore(AnyBackendConfigWithCredsTagged, resp.json()) def update( self, project_name: str, config: AnyBackendConfigWithCreds ) -> AnyBackendConfigWithCreds: - resp = self._request(f"/api/project/{project_name}/backends/update", body=config.json()) - return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) + resp = self._request( + f"/api/project/{project_name}/backends/update", body=config.model_dump_json() + ) + return validate_extra_ignore(AnyBackendConfigWithCredsTagged, resp.json()) def delete(self, project_name: str, backends_names: List[BackendType]): body = DeleteBackendsRequest(backends_names=backends_names) - self._request(f"/api/project/{project_name}/backends/delete", body=body.json()) + self._request(f"/api/project/{project_name}/backends/delete", body=body.model_dump_json()) def config_info( self, project_name: str, backend_name: BackendType ) -> AnyBackendConfigWithCreds: resp = self._request(f"/api/project/{project_name}/backends/{backend_name}/config_info") - return parse_obj_as(AnyBackendConfigWithCredsResponse, resp.json()) + return validate_extra_ignore(AnyBackendConfigWithCredsTagged, resp.json()) diff --git a/src/dstack/api/server/_events.py b/src/dstack/api/server/_events.py index 22cd8893cd..132d5994f4 100644 --- a/src/dstack/api/server/_events.py +++ b/src/dstack/api/server/_events.py @@ -2,9 +2,8 @@ from typing import Optional from uuid import UUID -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.events import get_list_events_excludes +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.events import Event, EventTargetType from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT, ListEventsRequest from dstack.api.server._group import APIClientGroup @@ -59,6 +58,6 @@ def list( ascending=ascending, ) resp = self._request( - "/api/events/list", body=req.json(exclude=get_list_events_excludes(req)) + "/api/events/list", body=req.model_dump_json(exclude=get_list_events_excludes(req)) ) - return parse_obj_as(list[Event.__response__], resp.json()) + return validate_extra_ignore(list[Event], resp.json()) diff --git a/src/dstack/api/server/_exports.py b/src/dstack/api/server/_exports.py index f23016011d..7330fbf6a1 100644 --- a/src/dstack/api/server/_exports.py +++ b/src/dstack/api/server/_exports.py @@ -1,11 +1,10 @@ from typing import List -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.exports import ( get_create_export_excludes, get_update_export_excludes, ) +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.exports import Export from dstack._internal.server.schemas.exports import ( CreateExportRequest, @@ -18,7 +17,7 @@ class ExportsAPIClient(APIClientGroup): def list(self, project_name: str) -> List[Export]: resp = self._request(f"/api/project/{project_name}/exports/list") - return parse_obj_as(List[Export.__response__], resp.json()) + return validate_extra_ignore(List[Export], resp.json()) def create( self, @@ -39,9 +38,9 @@ def create( ) resp = self._request( f"/api/project/{project_name}/exports/create", - body=body.json(exclude=get_create_export_excludes(body)), + body=body.model_dump_json(exclude=get_create_export_excludes(body)), ) - return parse_obj_as(Export.__response__, resp.json()) + return validate_extra_ignore(Export, resp.json()) def update( self, @@ -70,10 +69,10 @@ def update( ) resp = self._request( f"/api/project/{project_name}/exports/update", - body=body.json(exclude=get_update_export_excludes(body)), + body=body.model_dump_json(exclude=get_update_export_excludes(body)), ) - return parse_obj_as(Export.__response__, resp.json()) + return validate_extra_ignore(Export, resp.json()) def delete(self, project_name: str, name: str) -> None: body = DeleteExportRequest(name=name) - self._request(f"/api/project/{project_name}/exports/delete", body=body.json()) + self._request(f"/api/project/{project_name}/exports/delete", body=body.model_dump_json()) diff --git a/src/dstack/api/server/_files.py b/src/dstack/api/server/_files.py index e7bdde91a3..d5a7202daf 100644 --- a/src/dstack/api/server/_files.py +++ b/src/dstack/api/server/_files.py @@ -1,7 +1,6 @@ from typing import BinaryIO -from pydantic import parse_obj_as - +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.files import FileArchive from dstack._internal.server.schemas.files import GetFileArchiveByHashRequest from dstack.api.server._group import APIClientGroup @@ -10,9 +9,9 @@ class FilesAPIClient(APIClientGroup): def get_archive_by_hash(self, hash: str) -> FileArchive: body = GetFileArchiveByHashRequest(hash=hash) - resp = self._request("/api/files/get_archive_by_hash", body=body.json()) - return parse_obj_as(FileArchive.__response__, resp.json()) + resp = self._request("/api/files/get_archive_by_hash", body=body.model_dump_json()) + return validate_extra_ignore(FileArchive, resp.json()) def upload_archive(self, hash: str, fp: BinaryIO) -> FileArchive: resp = self._request("/api/files/upload_archive", files={"file": (hash, fp)}) - return parse_obj_as(FileArchive.__response__, resp.json()) + return validate_extra_ignore(FileArchive, resp.json()) diff --git a/src/dstack/api/server/_fleets.py b/src/dstack/api/server/_fleets.py index 93f27e6728..e4780890ec 100644 --- a/src/dstack/api/server/_fleets.py +++ b/src/dstack/api/server/_fleets.py @@ -2,14 +2,13 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.fleets import ( get_apply_plan_excludes, get_create_fleet_excludes, get_get_plan_excludes, patch_fleet_spec, ) +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.fleets import ApplyFleetPlanInput, Fleet, FleetPlan, FleetSpec from dstack._internal.server.schemas.fleets import ( ApplyFleetPlanRequest, @@ -26,8 +25,10 @@ class FleetsAPIClient(APIClientGroup): def list(self, project_name: str, *, include_imported: bool = False) -> List[Fleet]: body = ListProjectFleetsRequest(include_imported=include_imported) - resp = self._request(f"/api/project/{project_name}/fleets/list", body=body.json()) - return parse_obj_as(List[Fleet.__response__], resp.json()) + resp = self._request( + f"/api/project/{project_name}/fleets/list", body=body.model_dump_json() + ) + return validate_extra_ignore(List[Fleet], resp.json()) def get( self, project_name: str, name: Optional[str] = None, fleet_id: Optional[UUID] = None @@ -39,9 +40,9 @@ def get( body = GetFleetRequest(name=name, id=fleet_id) resp = self._request( f"/api/project/{project_name}/fleets/get", - body=body.json(), + body=body.model_dump_json(), ) - return parse_obj_as(Fleet.__response__, resp.json()) + return validate_extra_ignore(Fleet, resp.json()) def get_plan( self, @@ -51,9 +52,9 @@ def get_plan( body = GetFleetPlanRequest(spec=spec) body = copy.deepcopy(body) patch_fleet_spec(body.spec) - body_json = body.json(exclude=get_get_plan_excludes(spec)) + body_json = body.model_dump_json(exclude=get_get_plan_excludes(spec)) resp = self._request(f"/api/project/{project_name}/fleets/get_plan", body=body_json) - return parse_obj_as(FleetPlan.__response__, resp.json()) + return validate_extra_ignore(FleetPlan, resp.json()) def apply_plan( self, @@ -61,23 +62,25 @@ def apply_plan( plan: Union[FleetPlan, ApplyFleetPlanInput], force: bool = False, ) -> Fleet: - plan_input = ApplyFleetPlanInput.__response__.parse_obj(plan) + plan_input = validate_extra_ignore(ApplyFleetPlanInput, plan) body = ApplyFleetPlanRequest(plan=plan_input, force=force) body = copy.deepcopy(body) patch_fleet_spec(body.plan.spec) if body.plan.current_resource is not None: patch_fleet_spec(body.plan.current_resource.spec) - body_json = body.json(exclude=get_apply_plan_excludes(plan_input)) + body_json = body.model_dump_json(exclude=get_apply_plan_excludes(plan_input)) resp = self._request(f"/api/project/{project_name}/fleets/apply", body=body_json) - return parse_obj_as(Fleet.__response__, resp.json()) + return validate_extra_ignore(Fleet, resp.json()) def delete(self, project_name: str, names: List[str]) -> None: body = DeleteFleetsRequest(names=names) - self._request(f"/api/project/{project_name}/fleets/delete", body=body.json()) + self._request(f"/api/project/{project_name}/fleets/delete", body=body.model_dump_json()) def delete_instances(self, project_name: str, name: str, instance_nums: List[int]) -> None: body = DeleteFleetInstancesRequest(name=name, instance_nums=instance_nums) - self._request(f"/api/project/{project_name}/fleets/delete_instances", body=body.json()) + self._request( + f"/api/project/{project_name}/fleets/delete_instances", body=body.model_dump_json() + ) # Deprecated # TODO: Remove in 0.21 @@ -89,6 +92,6 @@ def create( body = CreateFleetRequest(spec=spec) body = copy.deepcopy(body) patch_fleet_spec(body.spec) - body_json = body.json(exclude=get_create_fleet_excludes(spec)) + body_json = body.model_dump_json(exclude=get_create_fleet_excludes(spec)) resp = self._request(f"/api/project/{project_name}/fleets/create", body=body_json) - return parse_obj_as(Fleet.__response__, resp.json()) + return validate_extra_ignore(Fleet, resp.json()) diff --git a/src/dstack/api/server/_gateways.py b/src/dstack/api/server/_gateways.py index f811894615..715be0f5a2 100644 --- a/src/dstack/api/server/_gateways.py +++ b/src/dstack/api/server/_gateways.py @@ -1,11 +1,10 @@ from typing import List, Optional -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.gateways import ( get_create_gateway_excludes, get_set_default_gateway_excludes, ) +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.gateways import ( ApplyGatewayPlanInput, Gateway, @@ -31,25 +30,33 @@ def list(self, project_name: str, *, include_imported: bool = False) -> List[Gat body = ListGatewaysRequest( include_imported=include_imported, ) - resp = self._request(f"/api/project/{project_name}/gateways/list", body=body.json()) - return parse_obj_as(List[Gateway.__response__], resp.json()) + resp = self._request( + f"/api/project/{project_name}/gateways/list", body=body.model_dump_json() + ) + return validate_extra_ignore(List[Gateway], resp.json()) def get(self, project_name: str, gateway_name: str) -> Gateway: body = GetGatewayRequest(name=gateway_name) - resp = self._request(f"/api/project/{project_name}/gateways/get", body=body.json()) - return parse_obj_as(Gateway.__response__, resp.json()) + resp = self._request( + f"/api/project/{project_name}/gateways/get", body=body.model_dump_json() + ) + return validate_extra_ignore(Gateway, resp.json()) def get_plan(self, project_name: str, spec: GatewaySpec) -> GatewayPlan: body = GetGatewayPlanRequest(spec=spec) - resp = self._request(f"/api/project/{project_name}/gateways/get_plan", body=body.json()) - return parse_obj_as(GatewayPlan.__response__, resp.json()) + resp = self._request( + f"/api/project/{project_name}/gateways/get_plan", body=body.model_dump_json() + ) + return validate_extra_ignore(GatewayPlan, resp.json()) def apply_plan( self, project_name: str, plan: ApplyGatewayPlanInput, *, force: bool = False ) -> Gateway: body = ApplyGatewayPlanRequest(plan=plan, force=force) - resp = self._request(f"/api/project/{project_name}/gateways/apply", body=body.json()) - return parse_obj_as(Gateway.__response__, resp.json()) + resp = self._request( + f"/api/project/{project_name}/gateways/apply", body=body.model_dump_json() + ) + return validate_extra_ignore(Gateway, resp.json()) def create( self, @@ -59,13 +66,13 @@ def create( body = CreateGatewayRequest(configuration=configuration) resp = self._request( f"/api/project/{project_name}/gateways/create", - body=body.json(exclude=get_create_gateway_excludes(configuration)), + body=body.model_dump_json(exclude=get_create_gateway_excludes(configuration)), ) - return parse_obj_as(Gateway.__response__, resp.json()) + return validate_extra_ignore(Gateway, resp.json()) def delete(self, project_name: str, gateways_names: List[str]) -> None: body = DeleteGatewaysRequest(names=gateways_names) - self._request(f"/api/project/{project_name}/gateways/delete", body=body.json()) + self._request(f"/api/project/{project_name}/gateways/delete", body=body.model_dump_json()) def set_default( self, project_name: str, gateway_name: str, *, gateway_project: Optional[str] = None @@ -75,7 +82,7 @@ def set_default( body = SetDefaultGatewayRequest(name=gateway_name, gateway_project=gateway_project) self._request( f"/api/project/{project_name}/gateways/set_default", - body=body.json(exclude=get_set_default_gateway_excludes(body)), + body=body.model_dump_json(exclude=get_set_default_gateway_excludes(body)), ) def set_wildcard_domain( @@ -83,6 +90,7 @@ def set_wildcard_domain( ) -> Gateway: body = SetWildcardDomainRequest(name=gateway_name, wildcard_domain=wildcard_domain) resp = self._request( - f"/api/project/{project_name}/gateways/set_wildcard_domain", body=body.json() + f"/api/project/{project_name}/gateways/set_wildcard_domain", + body=body.model_dump_json(), ) - return parse_obj_as(Gateway.__response__, resp.json()) + return validate_extra_ignore(Gateway, resp.json()) diff --git a/src/dstack/api/server/_gpus.py b/src/dstack/api/server/_gpus.py index 78886d4d4e..9939eda4f0 100644 --- a/src/dstack/api/server/_gpus.py +++ b/src/dstack/api/server/_gpus.py @@ -1,8 +1,7 @@ from typing import List, Literal, Optional, cast -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.gpus import get_list_gpus_excludes +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.gpus import GpuGroup from dstack._internal.core.models.runs import RunSpec from dstack._internal.server.schemas.gpus import ListGpusRequest, ListGpusResponse @@ -26,6 +25,6 @@ def list_gpus( ) resp = self._request( f"/api/project/{project_name}/gpus/list", - body=body.json(exclude=get_list_gpus_excludes(body)), + body=body.model_dump_json(exclude=get_list_gpus_excludes(body)), ) - return parse_obj_as(ListGpusResponse.__response__, resp.json()).gpus + return validate_extra_ignore(ListGpusResponse, resp.json()).gpus diff --git a/src/dstack/api/server/_group.py b/src/dstack/api/server/_group.py index 9d3ec1918a..fae9dfcc87 100644 --- a/src/dstack/api/server/_group.py +++ b/src/dstack/api/server/_group.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional +from typing import Optional, Union import requests from typing_extensions import Protocol @@ -9,7 +9,7 @@ class APIRequest(Protocol): def __call__( self, path: str, - body: Optional[str] = None, + body: Optional[Union[str, bytes]] = None, raise_for_status: bool = True, method: str = "POST", **kwargs, diff --git a/src/dstack/api/server/_imports.py b/src/dstack/api/server/_imports.py index bcc1abb162..4fc712bf60 100644 --- a/src/dstack/api/server/_imports.py +++ b/src/dstack/api/server/_imports.py @@ -1,7 +1,6 @@ from typing import List -from pydantic import parse_obj_as - +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.imports import Import from dstack._internal.server.schemas.imports import DeleteImportRequest from dstack.api.server._group import APIClientGroup @@ -10,10 +9,10 @@ class ImportsAPIClient(APIClientGroup): def list(self, project_name: str) -> List[Import]: resp = self._request(f"/api/project/{project_name}/imports/list") - return parse_obj_as(List[Import.__response__], resp.json()) + return validate_extra_ignore(List[Import], resp.json()) def delete(self, *, project_name: str, export_project_name: str, export_name: str) -> None: body = DeleteImportRequest( export_project_name=export_project_name, export_name=export_name ) - self._request(f"/api/project/{project_name}/imports/delete", body=body.json()) + self._request(f"/api/project/{project_name}/imports/delete", body=body.model_dump_json()) diff --git a/src/dstack/api/server/_logs.py b/src/dstack/api/server/_logs.py index 7cdfc246f7..7a709dbc6e 100644 --- a/src/dstack/api/server/_logs.py +++ b/src/dstack/api/server/_logs.py @@ -1,6 +1,5 @@ -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.logs import get_poll_logs_excludes +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.logs import JobSubmissionLogs from dstack._internal.server.schemas.logs import PollLogsRequest from dstack.api.server._group import APIClientGroup @@ -10,6 +9,6 @@ class LogsAPIClient(APIClientGroup): def poll(self, project_name: str, body: PollLogsRequest) -> JobSubmissionLogs: resp = self._request( f"/api/project/{project_name}/logs/poll", - body=body.json(exclude=get_poll_logs_excludes(body)), + body=body.model_dump_json(exclude=get_poll_logs_excludes(body)), ) - return parse_obj_as(JobSubmissionLogs.__response__, resp.json()) + return validate_extra_ignore(JobSubmissionLogs, resp.json()) diff --git a/src/dstack/api/server/_metrics.py b/src/dstack/api/server/_metrics.py index 8b378c89b3..b9604461db 100644 --- a/src/dstack/api/server/_metrics.py +++ b/src/dstack/api/server/_metrics.py @@ -1,5 +1,4 @@ -from pydantic import parse_obj_as - +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.metrics import JobMetrics from dstack.api.server._group import APIClientGroup @@ -20,4 +19,4 @@ def get_job_metrics( "job_num": job_num, }, ) - return parse_obj_as(JobMetrics.__response__, resp.json()) + return validate_extra_ignore(JobMetrics, resp.json()) diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index 6c45f63364..8feff6630f 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -1,10 +1,10 @@ -import json from datetime import datetime from typing import Any, List, Literal, Optional, Union, overload from uuid import UUID -from pydantic import parse_obj_as +from pydantic_core import to_json +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.projects import ( Project, ProjectsInfoList, @@ -71,54 +71,64 @@ def list( if name_pattern is not None: body["name_pattern"] = name_pattern if prev_created_at is not None: - body["prev_created_at"] = prev_created_at.isoformat() + body["prev_created_at"] = prev_created_at if prev_id is not None: - body["prev_id"] = str(prev_id) + body["prev_id"] = prev_id if limit is not None: body["limit"] = limit if ascending is not None: body["ascending"] = ascending - resp = self._request("/api/projects/list", body=json.dumps(body)) + resp = self._request("/api/projects/list", body=to_json(body)) resp_json = resp.json() if isinstance(resp_json, list): - return parse_obj_as(List[Project.__response__], resp_json) - return parse_obj_as(ProjectsInfoList.__response__, resp_json) + return validate_extra_ignore(List[Project], resp_json) + return validate_extra_ignore(ProjectsInfoList, resp_json) def create(self, project_name: str, is_public: bool = False) -> Project: body = CreateProjectRequest(project_name=project_name, is_public=is_public) - resp = self._request("/api/projects/create", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request("/api/projects/create", body=body.model_dump_json()) + return validate_extra_ignore(Project, resp.json()) def delete(self, projects_names: List[str]): body = DeleteProjectsRequest(projects_names=projects_names) - self._request("/api/projects/delete", body=body.json()) + self._request("/api/projects/delete", body=body.model_dump_json()) def get(self, project_name: str) -> Project: resp = self._request(f"/api/projects/{project_name}/get") - return parse_obj_as(Project.__response__, resp.json()) + return validate_extra_ignore(Project, resp.json()) def set_members(self, project_name: str, members: List[MemberSetting]) -> Project: body = SetProjectMembersRequest(members=members) - resp = self._request(f"/api/projects/{project_name}/set_members", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request( + f"/api/projects/{project_name}/set_members", body=body.model_dump_json() + ) + return validate_extra_ignore(Project, resp.json()) def add_member(self, project_name: str, username: str, project_role: ProjectRole) -> Project: member_setting = MemberSetting(username=username, project_role=project_role) body = AddProjectMemberRequest(members=[member_setting]) - resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request( + f"/api/projects/{project_name}/add_members", body=body.model_dump_json() + ) + return validate_extra_ignore(Project, resp.json()) def add_members(self, project_name: str, members: List[MemberSetting]) -> Project: body = AddProjectMemberRequest(members=members) - resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request( + f"/api/projects/{project_name}/add_members", body=body.model_dump_json() + ) + return validate_extra_ignore(Project, resp.json()) def remove_member(self, project_name: str, username: str) -> Project: body = RemoveProjectMemberRequest(usernames=[username]) - resp = self._request(f"/api/projects/{project_name}/remove_members", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request( + f"/api/projects/{project_name}/remove_members", body=body.model_dump_json() + ) + return validate_extra_ignore(Project, resp.json()) def remove_members(self, project_name: str, usernames: List[str]) -> Project: body = RemoveProjectMemberRequest(usernames=usernames) - resp = self._request(f"/api/projects/{project_name}/remove_members", body=body.json()) - return parse_obj_as(Project.__response__, resp.json()) + resp = self._request( + f"/api/projects/{project_name}/remove_members", body=body.model_dump_json() + ) + return validate_extra_ignore(Project, resp.json()) diff --git a/src/dstack/api/server/_repos.py b/src/dstack/api/server/_repos.py index 03f9eb9eab..89a34da5ee 100644 --- a/src/dstack/api/server/_repos.py +++ b/src/dstack/api/server/_repos.py @@ -1,7 +1,6 @@ from typing import BinaryIO, List, Optional -from pydantic import parse_obj_as - +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.repos import ( AnyRepoInfo, RemoteRepoCreds, @@ -19,7 +18,7 @@ class ReposAPIClient(APIClientGroup): def list(self, project_name: str) -> List[RepoHead]: resp = self._request(f"/api/project/{project_name}/repos/list") - return parse_obj_as(List[RepoHead.__response__], resp.json()) + return validate_extra_ignore(List[RepoHead], resp.json()) def get( self, project_name: str, repo_id: str, include_creds: Optional[bool] = None @@ -30,13 +29,13 @@ def get( " the repo without creds. Use `get_with_creds()` to get the repo with creds" ) body = GetRepoRequest(repo_id=repo_id, include_creds=False) - resp = self._request(f"/api/project/{project_name}/repos/get", body=body.json()) - return parse_obj_as(RepoHead.__response__, resp.json()) + resp = self._request(f"/api/project/{project_name}/repos/get", body=body.model_dump_json()) + return validate_extra_ignore(RepoHead, resp.json()) def get_with_creds(self, project_name: str, repo_id: str) -> RepoHeadWithCreds: body = GetRepoRequest(repo_id=repo_id, include_creds=True) - resp = self._request(f"/api/project/{project_name}/repos/get", body=body.json()) - return parse_obj_as(RepoHeadWithCreds.__response__, resp.json()) + resp = self._request(f"/api/project/{project_name}/repos/get", body=body.model_dump_json()) + return validate_extra_ignore(RepoHeadWithCreds, resp.json()) def init( self, @@ -50,11 +49,11 @@ def init( repo_info=repo_info, repo_creds=repo_creds, ) - self._request(f"/api/project/{project_name}/repos/init", body=body.json()) + self._request(f"/api/project/{project_name}/repos/init", body=body.model_dump_json()) def delete(self, project_name: str, repos_ids: List[str]): body = DeleteReposRequest(repos_ids=repos_ids) - self._request(f"/api/project/{project_name}/repos/delete", body=body.json()) + self._request(f"/api/project/{project_name}/repos/delete", body=body.model_dump_json()) def upload_code(self, project_name: str, repo_id: str, code_hash: str, fp: BinaryIO): self._request( diff --git a/src/dstack/api/server/_runs.py b/src/dstack/api/server/_runs.py index e0e0bdd48a..41d5e096af 100644 --- a/src/dstack/api/server/_runs.py +++ b/src/dstack/api/server/_runs.py @@ -3,14 +3,13 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.runs import ( get_apply_plan_excludes, get_get_plan_excludes, get_list_runs_excludes, patch_run_spec, ) +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.runs import ( ApplyRunPlanInput, Run, @@ -55,9 +54,9 @@ def list( ascending=ascending, ) resp = self._request( - "/api/runs/list", body=body.json(exclude=get_list_runs_excludes(body)) + "/api/runs/list", body=body.model_dump_json(exclude=get_list_runs_excludes(body)) ) - return parse_obj_as(List[Run.__response__], resp.json()) + return validate_extra_ignore(List[Run], resp.json()) def get( self, project_name: str, run_name: Optional[str] = None, run_id: Optional[UUID] = None @@ -67,9 +66,9 @@ def get( if run_name is not None and run_id is not None: raise ValueError("Cannot specify both run_name and run_id") body = GetRunRequest(run_name=run_name, id=run_id) - json_body = body.json() + json_body = body.model_dump_json() resp = self._request(f"/api/project/{project_name}/runs/get", body=json_body) - return parse_obj_as(Run.__response__, resp.json()) + return validate_extra_ignore(Run, resp.json()) def get_plan( self, @@ -89,9 +88,9 @@ def get_plan( patch_run_spec(body.run_spec) resp = self._request( f"/api/project/{project_name}/runs/get_plan", - body=body.json(exclude=get_get_plan_excludes(body)), + body=body.model_dump_json(exclude=get_get_plan_excludes(body)), ) - return parse_obj_as(RunPlan.__response__, resp.json()) + return validate_extra_ignore(RunPlan, resp.json()) def apply_plan( self, @@ -99,7 +98,7 @@ def apply_plan( plan: Union[RunPlan, ApplyRunPlanInput], force: bool = False, ) -> Run: - plan_input: ApplyRunPlanInput = ApplyRunPlanInput.__response__.parse_obj(plan) + plan_input: ApplyRunPlanInput = validate_extra_ignore(ApplyRunPlanInput, plan) body = ApplyRunPlanRequest(plan=plan_input, force=force) body = copy.deepcopy(body) patch_run_spec(body.plan.run_spec) @@ -107,14 +106,14 @@ def apply_plan( patch_run_spec(body.plan.current_resource.run_spec) resp = self._request( f"/api/project/{project_name}/runs/apply", - body=body.json(exclude=get_apply_plan_excludes(plan_input)), + body=body.model_dump_json(exclude=get_apply_plan_excludes(plan_input)), ) - return parse_obj_as(Run.__response__, resp.json()) + return validate_extra_ignore(Run, resp.json()) def stop(self, project_name: str, runs_names: List[str], abort: bool): body = StopRunsRequest(runs_names=runs_names, abort=abort) - self._request(f"/api/project/{project_name}/runs/stop", body=body.json()) + self._request(f"/api/project/{project_name}/runs/stop", body=body.model_dump_json()) def delete(self, project_name: str, runs_names: List[str]): body = DeleteRunsRequest(runs_names=runs_names) - self._request(f"/api/project/{project_name}/runs/delete", body=body.json()) + self._request(f"/api/project/{project_name}/runs/delete", body=body.model_dump_json()) diff --git a/src/dstack/api/server/_secrets.py b/src/dstack/api/server/_secrets.py index 1efcdac36e..2c0d050d27 100644 --- a/src/dstack/api/server/_secrets.py +++ b/src/dstack/api/server/_secrets.py @@ -1,7 +1,6 @@ from typing import List -from pydantic import parse_obj_as - +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.secrets import Secret from dstack._internal.server.schemas.secrets import ( CreateOrUpdateSecretRequest, @@ -14,12 +13,14 @@ class SecretsAPIClient(APIClientGroup): def list(self, project_name: str) -> List[Secret]: resp = self._request(f"/api/project/{project_name}/secrets/list") - return parse_obj_as(List[Secret.__response__], resp.json()) + return validate_extra_ignore(List[Secret], resp.json()) def get(self, project_name: str, name: str) -> Secret: body = GetSecretRequest(name=name) - resp = self._request(f"/api/project/{project_name}/secrets/get", body=body.json()) - return parse_obj_as(Secret.__response__, resp.json()) + resp = self._request( + f"/api/project/{project_name}/secrets/get", body=body.model_dump_json() + ) + return validate_extra_ignore(Secret, resp.json()) def create_or_update(self, project_name: str, name: str, value: str) -> Secret: body = CreateOrUpdateSecretRequest( @@ -27,10 +28,10 @@ def create_or_update(self, project_name: str, name: str, value: str) -> Secret: value=value, ) resp = self._request( - f"/api/project/{project_name}/secrets/create_or_update", body=body.json() + f"/api/project/{project_name}/secrets/create_or_update", body=body.model_dump_json() ) - return parse_obj_as(Secret.__response__, resp.json()) + return validate_extra_ignore(Secret, resp.json()) def delete(self, project_name: str, names: List[str]): body = DeleteSecretsRequest(secrets_names=names) - self._request(f"/api/project/{project_name}/secrets/delete", body=body.json()) + self._request(f"/api/project/{project_name}/secrets/delete", body=body.model_dump_json()) diff --git a/src/dstack/api/server/_users.py b/src/dstack/api/server/_users.py index ff1bab3d48..91ac218536 100644 --- a/src/dstack/api/server/_users.py +++ b/src/dstack/api/server/_users.py @@ -1,11 +1,10 @@ -import json from datetime import datetime from typing import Any, List, Optional from uuid import UUID -from pydantic import parse_obj_as -from pydantic.json import pydantic_encoder +from pydantic_core import to_json +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.users import ( GlobalRole, User, @@ -47,36 +46,34 @@ def list( if ascending is not None: body["ascending"] = ascending if body: - resp = self._request( - "/api/users/list", body=json.dumps(body, default=pydantic_encoder) - ) + resp = self._request("/api/users/list", body=to_json(body)) else: resp = self._request("/api/users/list") resp_json = resp.json() if isinstance(resp_json, list): - return parse_obj_as(List[User.__response__], resp_json) - return parse_obj_as(UsersInfoList.__response__, resp_json) + return validate_extra_ignore(List[User], resp_json) + return validate_extra_ignore(UsersInfoList, resp_json) def get_my_user(self) -> UserWithCreds: resp = self._request("/api/users/get_my_user") - return parse_obj_as(UserWithCreds.__response__, resp.json()) + return validate_extra_ignore(UserWithCreds, resp.json()) def get_user(self, username: str) -> User: body = GetUserRequest(username=username) - resp = self._request("/api/users/get_user", body=body.json()) - return parse_obj_as(User.__response__, resp.json()) + resp = self._request("/api/users/get_user", body=body.model_dump_json()) + return validate_extra_ignore(User, resp.json()) def create(self, username: str, global_role: GlobalRole) -> User: body = CreateUserRequest(username=username, global_role=global_role, email=None) - resp = self._request("/api/users/create", body=body.json()) - return parse_obj_as(User.__response__, resp.json()) + resp = self._request("/api/users/create", body=body.model_dump_json()) + return validate_extra_ignore(User, resp.json()) def update(self, username: str, global_role: GlobalRole) -> User: body = UpdateUserRequest(username=username, global_role=global_role, email=None) - resp = self._request("/api/users/update", body=body.json()) - return parse_obj_as(User.__response__, resp.json()) + resp = self._request("/api/users/update", body=body.model_dump_json()) + return validate_extra_ignore(User, resp.json()) def refresh_token(self, username: str) -> UserWithCreds: body = RefreshTokenRequest(username=username) - resp = self._request("/api/users/refresh_token", body=body.json()) - return parse_obj_as(UserWithCreds.__response__, resp.json()) + resp = self._request("/api/users/refresh_token", body=body.model_dump_json()) + return validate_extra_ignore(UserWithCreds, resp.json()) diff --git a/src/dstack/api/server/_volumes.py b/src/dstack/api/server/_volumes.py index 5cf56afc3d..6187118a64 100644 --- a/src/dstack/api/server/_volumes.py +++ b/src/dstack/api/server/_volumes.py @@ -1,8 +1,7 @@ from typing import List -from pydantic import parse_obj_as - from dstack._internal.core.compatibility.volumes import get_create_volume_excludes +from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.volumes import AnyVolumeConfiguration, Volume from dstack._internal.server.schemas.volumes import ( CreateVolumeRequest, @@ -15,12 +14,14 @@ class VolumesAPIClient(APIClientGroup): def list(self, project_name: str) -> List[Volume]: resp = self._request(f"/api/project/{project_name}/volumes/list") - return parse_obj_as(List[Volume.__response__], resp.json()) + return validate_extra_ignore(List[Volume], resp.json()) def get(self, project_name: str, name: str) -> Volume: body = GetVolumeRequest(name=name) - resp = self._request(f"/api/project/{project_name}/volumes/get", body=body.json()) - return parse_obj_as(Volume.__response__, resp.json()) + resp = self._request( + f"/api/project/{project_name}/volumes/get", body=body.model_dump_json() + ) + return validate_extra_ignore(Volume, resp.json()) def create( self, @@ -30,10 +31,10 @@ def create( body = CreateVolumeRequest(configuration=configuration) resp = self._request( f"/api/project/{project_name}/volumes/create", - body=body.json(exclude=get_create_volume_excludes(configuration)), + body=body.model_dump_json(exclude=get_create_volume_excludes(configuration)), ) - return parse_obj_as(Volume.__response__, resp.json()) + return validate_extra_ignore(Volume, resp.json()) def delete(self, project_name: str, names: List[str]) -> None: body = DeleteVolumesRequest(names=names) - self._request(f"/api/project/{project_name}/volumes/delete", body=body.json()) + self._request(f"/api/project/{project_name}/volumes/delete", body=body.model_dump_json()) diff --git a/src/dstack/api/utils.py b/src/dstack/api/utils.py index 66bec859fb..1c559aab2c 100644 --- a/src/dstack/api/utils.py +++ b/src/dstack/api/utils.py @@ -90,7 +90,7 @@ def _load_profile_from_path(profiles_path: Path, profile_name: Optional[str]) -> try: with profiles_path.open("r") as f: - config = ProfilesConfig.parse_obj(yaml.safe_load(f)) + config = ProfilesConfig.model_validate(yaml.safe_load(f)) except FileNotFoundError: return None except ValidationError as e: diff --git a/src/dstack/plugins/builtin/rest_plugin/_models.py b/src/dstack/plugins/builtin/rest_plugin/_models.py index ee3a042464..28887bf86f 100644 --- a/src/dstack/plugins/builtin/rest_plugin/_models.py +++ b/src/dstack/plugins/builtin/rest_plugin/_models.py @@ -16,13 +16,6 @@ class SpecApplyRequest(BaseModel, Generic[SpecType]): project: Annotated[str, Field(description="The name of the project the request is for")] spec: Annotated[SpecType, Field(description="The spec to be applied")] - # Override dict() to remove __orig_class__ attribute and avoid "TypeError: Object of type _GenericAlias is not JSON serializable" - # error. This issue doesn't happen though when running the code in pytest, only when running the server. - def dict(self, *args, **kwargs): - d = super().dict(*args, **kwargs) - d.pop("__orig_class__", None) - return d - RunSpecRequest = SpecApplyRequest[RunSpec] FleetSpecRequest = SpecApplyRequest[FleetSpec] diff --git a/src/dstack/plugins/builtin/rest_plugin/_plugin.py b/src/dstack/plugins/builtin/rest_plugin/_plugin.py index 210dd50e19..a4d796fd1a 100644 --- a/src/dstack/plugins/builtin/rest_plugin/_plugin.py +++ b/src/dstack/plugins/builtin/rest_plugin/_plugin.py @@ -55,10 +55,13 @@ def _call_plugin_service( excludes: Optional[Dict], ) -> ApplySpec: response = None + # `{"spec": None}` is not a valid `exclude` value in pydantic v2 (it wants a set, a nested + # mapping, or a bool), so omit the key entirely when there is nothing to exclude. + exclude = {"spec": excludes} if excludes is not None else None try: response = requests.post( f"{self._plugin_service_uri}{endpoint}", - json=spec_request.dict(exclude={"spec": excludes}), + json=spec_request.model_dump(exclude=exclude), headers={"accept": "application/json", "Content-Type": "application/json"}, timeout=PLUGIN_REQUEST_TIMEOUT_SEC, ) diff --git a/src/tests/_internal/cli/commands/test_preset.py b/src/tests/_internal/cli/commands/test_preset.py index 5f8c17c654..84b88ae3d0 100644 --- a/src/tests/_internal/cli/commands/test_preset.py +++ b/src/tests/_internal/cli/commands/test_preset.py @@ -8,6 +8,7 @@ from dstack._internal.cli.services.presets import output as presets_utils from dstack._internal.cli.services.presets.store import PresetStore +from dstack._internal.utils.common import render_datetime_as_api from tests._internal.cli.common import plain_console, run_dstack_cli from tests._internal.cli.preset_factories import get_preset @@ -162,7 +163,7 @@ def test_gets_complete_preset_as_json_without_api_client(self, tmp_path, capsys) data = json.loads(capsys.readouterr().out) assert data["id"] == preset.id - assert data["created_at"] == preset.created_at.isoformat() + assert data["created_at"] == render_datetime_as_api(preset.created_at) assert data["context_length"] == 32768 assert data["validations"][0]["benchmark"]["metrics"]["total_output_tokens"] == 2048 @@ -183,7 +184,7 @@ def test_lists_complete_presets_as_json(self, tmp_path, capsys, args): assert len(output["presets"]) == 1 data = output["presets"][0] assert data["id"] == preset.id - assert data["created_at"] == preset.created_at.isoformat() + assert data["created_at"] == render_datetime_as_api(preset.created_at) assert data["context_length"] == 32768 assert data["validations"][0]["benchmark"]["metrics"]["total_output_tokens"] == 2048 @@ -195,10 +196,12 @@ def test_deletes_all_presets_of_model_keeping_others_without_api_client( preset = get_preset() store = PresetStore(tmp_path / ".dstack" / "presets") store.save(preset) - store.save(preset.copy(update={"id": "01234567"})) + store.save(preset.model_copy(update={"id": "01234567"})) # A preset of a different model must survive the delete. store.save( - preset.copy(update={"id": "89abcdef", "base": "meta/Llama-4", "model": "meta/Llama-4"}) + preset.model_copy( + update={"id": "89abcdef", "base": "meta/Llama-4", "model": "meta/Llama-4"} + ) ) with patch("dstack.api.Client.from_config") as from_config: @@ -220,7 +223,9 @@ def test_lists_presets_filtered_by_model(self, tmp_path, capsys, flag_attribute) store = PresetStore(tmp_path / ".dstack" / "presets") store.save(preset) store.save( - preset.copy(update={"id": "01234567", "base": "meta/Llama-4", "model": "meta/Llama-4"}) + preset.model_copy( + update={"id": "01234567", "base": "meta/Llama-4", "model": "meta/Llama-4"} + ) ) args = ["preset", "list", "--json", flag, getattr(preset, attribute)] @@ -368,7 +373,7 @@ def test_apply_requires_preset_id(self, tmp_path, capsys): class TestPresetNameClaims: def test_create_detaches_the_name_from_the_old_preset(self, tmp_path): - preset = get_preset().copy(update={"name": "qwen"}) + preset = get_preset().model_copy(update={"name": "qwen"}) store = PresetStore(tmp_path / ".dstack" / "presets") store.save(preset) configuration_path = tmp_path / "preset.dstack.yml" @@ -391,7 +396,7 @@ def test_create_detaches_the_name_from_the_old_preset(self, tmp_path): assert store.get(preset.id).name is None def test_create_without_confirmation_exits_before_creating(self, tmp_path): - preset = get_preset().copy(update={"name": "qwen"}) + preset = get_preset().model_copy(update={"name": "qwen"}) store = PresetStore(tmp_path / ".dstack" / "presets") store.save(preset) configuration_path = tmp_path / "preset.dstack.yml" @@ -414,7 +419,7 @@ def test_create_without_confirmation_exits_before_creating(self, tmp_path): assert store.get(preset.id).name == "qwen" def test_get_and_delete_resolve_names(self, tmp_path, capsys): - preset = get_preset().copy(update={"name": "qwen"}) + preset = get_preset().model_copy(update={"name": "qwen"}) PresetStore(tmp_path / ".dstack" / "presets").save(preset) assert run_dstack_cli(["preset", "get", "qwen", "--json"], home_dir=tmp_path) == 0 diff --git a/src/tests/_internal/cli/models/test_configurations.py b/src/tests/_internal/cli/models/test_configurations.py index 81b17146d3..ebe39e2412 100644 --- a/src/tests/_internal/cli/models/test_configurations.py +++ b/src/tests/_internal/cli/models/test_configurations.py @@ -12,12 +12,12 @@ class TestPresetConfiguration: def test_schema_documents_supported_input(self): - assert all( - field.field_info.description for field in PresetConfiguration.__fields__.values() - ) - assert all(field.field_info.description for field in PresetModelBase.__fields__.values()) - assert all(field.field_info.description for field in PresetModelRepo.__fields__.values()) - assert {"type": "string"} in PresetConfiguration.schema()["properties"]["model"]["anyOf"] + assert all(field.description for field in PresetConfiguration.model_fields.values()) + assert all(field.description for field in PresetModelBase.model_fields.values()) + assert all(field.description for field in PresetModelRepo.model_fields.values()) + assert {"type": "string"} in PresetConfiguration.model_json_schema()["properties"][ + "model" + ]["anyOf"] def test_parses_string_as_exact_repo(self): configuration = PresetConfiguration(model="Qwen/Qwen3.5-27B") @@ -67,7 +67,7 @@ def test_parses_top_level_repo_shorthand(self): def test_shorthand_round_trips_through_dict(self): configuration = PresetConfiguration(base="Qwen/Qwen3.5-27B") - round_tripped = PresetConfiguration.parse_obj(configuration.dict()) + round_tripped = PresetConfiguration.model_validate(configuration.model_dump()) assert round_tripped.model == configuration.model diff --git a/src/tests/_internal/cli/models/test_presets.py b/src/tests/_internal/cli/models/test_presets.py index d71e7eba8d..856f13bb91 100644 --- a/src/tests/_internal/cli/models/test_presets.py +++ b/src/tests/_internal/cli/models/test_presets.py @@ -16,19 +16,19 @@ class TestPresetBenchmark: def test_agent_schema_matches_benchmark_model(self): schema = AGENT_FINAL_REPORT_JSON_SCHEMA["properties"]["benchmark"] - assert set(schema["properties"]) == set(PresetBenchmark.__fields__) - { + assert set(schema["properties"]) == set(PresetBenchmark.model_fields) - { "target", "client", } assert set(schema["required"]) == set(schema["properties"]) workload_schema = schema["properties"]["workload"] metrics_schema = schema["properties"]["metrics"] - assert set(workload_schema["properties"]) == set(PresetBenchmarkWorkload.__fields__) + assert set(workload_schema["properties"]) == set(PresetBenchmarkWorkload.model_fields) assert set(workload_schema["required"]) == set(workload_schema["properties"]) - assert set(metrics_schema["properties"]) == set(PresetBenchmarkMetrics.__fields__) + assert set(metrics_schema["properties"]) == set(PresetBenchmarkMetrics.model_fields) assert set(metrics_schema["required"]) == set(metrics_schema["properties"]) assert set(metrics_schema["properties"]["ttft_ms"]["properties"]) == set( - PresetBenchmarkLatency.__fields__ + PresetBenchmarkLatency.model_fields ) @pytest.mark.parametrize( @@ -39,14 +39,16 @@ def test_agent_schema_matches_benchmark_model(self): ], ) def test_rejects_inconsistent_successful_metrics(self, field, value, error): - data = get_preset_benchmark().dict() + data = get_preset_benchmark().model_dump() data["metrics"][field] = value with pytest.raises(ValidationError, match=error): - PresetBenchmark.parse_obj(data) + PresetBenchmark.model_validate(data) def test_rejects_tool_specific_metrics(self): - data = get_preset_benchmark().dict() + data = get_preset_benchmark().model_dump() data["metrics"]["tool_specific"] = 1 - with pytest.raises(ValidationError, match="extra fields not permitted"): - PresetBenchmark.parse_obj(data) + # No `match=`: the wording is pydantic's, and v2 rewords it ("Extra inputs are not + # permitted" rather than "extra fields not permitted"). What matters is the rejection. + with pytest.raises(ValidationError): + PresetBenchmark.model_validate(data) diff --git a/src/tests/_internal/cli/preset_factories.py b/src/tests/_internal/cli/preset_factories.py index 3104447d7a..6d5f6cad1e 100644 --- a/src/tests/_internal/cli/preset_factories.py +++ b/src/tests/_internal/cli/preset_factories.py @@ -41,7 +41,7 @@ def get_preset_benchmark(*, verified: bool = True) -> PresetBenchmark: ) if not verified: return benchmark - return benchmark.copy( + return benchmark.model_copy( update={ "target": PresetBenchmarkTarget(type="server-proxy"), "client": PresetBenchmarkClient(type="local"), @@ -54,7 +54,7 @@ def get_preset( preset_id: str = "8f3a12c4", context_length: int = 32768, ) -> Preset: - resources = ResourcesSpec.parse_obj( + resources = ResourcesSpec.model_validate( { "cpu": "16", "memory": "64GB", @@ -68,7 +68,7 @@ def get_preset( model="community/Qwen3.5-27B-GPTQ-Int4", context_length=context_length, created_at=datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc), - service=ServiceConfiguration.parse_obj( + service=ServiceConfiguration.model_validate( { "image": "vllm/vllm-openai:v0.11.0", "commands": ["vllm serve community/Qwen3.5-27B-GPTQ-Int4"], @@ -88,7 +88,7 @@ def get_preset( def get_running_service_run() -> Run: - service = ServiceConfiguration.parse_obj( + service = ServiceConfiguration.model_validate( { "name": "qwen-build-2", "image": "vllm/vllm-openai:v0.11.0", @@ -125,7 +125,7 @@ def get_running_service_run() -> Run: ) ], ) - return Run.construct( + return Run.model_construct( id=uuid4(), project_name="main", status=RunStatus.RUNNING, diff --git a/src/tests/_internal/cli/services/configurators/test_fleet.py b/src/tests/_internal/cli/services/configurators/test_fleet.py index f1d0bfe222..80bc215e98 100644 --- a/src/tests/_internal/cli/services/configurators/test_fleet.py +++ b/src/tests/_internal/cli/services/configurators/test_fleet.py @@ -29,7 +29,7 @@ def create_conf() -> FleetConfiguration: - return FleetConfiguration.parse_obj({"ssh_config": {"hosts": ["1.2.3.4"]}}) + return FleetConfiguration.model_validate({"ssh_config": {"hosts": ["1.2.3.4"]}}) def apply_args( @@ -38,7 +38,7 @@ def apply_args( parser = argparse.ArgumentParser() configurator = FleetConfigurator(Mock()) configurator.register_args(parser) - conf = conf.copy(deep=True) + conf = conf.model_copy(deep=True) configurator_args = parser.parse_args(args) configurator.apply_args(conf, configurator_args) return conf, configurator_args @@ -71,7 +71,7 @@ def get_ssh_fleet_spec( if hosts is None: hosts = ["10.0.0.100"] return FleetSpec( - configuration=FleetConfiguration.parse_obj( + configuration=FleetConfiguration.model_validate( { "name": name, "ssh_config": {"hosts": hosts}, @@ -130,23 +130,23 @@ class TestFleetConfigurator: def test_env(self): conf = create_conf() modified, args = apply_args(conf, ["-e", "A=1", "--env", "B=2"]) - conf.env = Env.parse_obj({"A": "1", "B": "2"}) - assert modified.dict() == conf.dict() + conf.env = Env.model_validate({"A": "1", "B": "2"}) + assert modified.model_dump() == conf.model_dump() def test_env_override(self): conf = create_conf() - conf.env = Env.parse_obj({"A": "0"}) + conf.env = Env.model_validate({"A": "0"}) modified, args = apply_args(conf, ["-e", "A=1", "--env", "B=2"]) - conf.env = Env.parse_obj({"A": "1", "B": "2"}) - assert modified.dict() == conf.dict() + conf.env = Env.model_validate({"A": "1", "B": "2"}) + assert modified.model_dump() == conf.model_dump() def test_env_value_from_environ(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("FROM_ENV", "2") conf = create_conf() - conf.env = Env.parse_obj({"FROM_CONF": "1"}) + conf.env = Env.model_validate({"FROM_CONF": "1"}) modified, args = apply_args(conf, ["--env", "FROM_ENV"]) - conf.env = Env.parse_obj({"FROM_CONF": "1", "FROM_ENV": "2"}) - assert modified.dict() == conf.dict() + conf.env = Env.model_validate({"FROM_CONF": "1", "FROM_ENV": "2"}) + assert modified.model_dump() == conf.model_dump() def test_env_value_from_environ_not_set(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("FROM_ENV", raising=False) @@ -199,7 +199,7 @@ def test_prints_no_diff_message(self, monkeypatch: pytest.MonkeyPatch): spec = get_cloud_fleet_spec() plan = create_fleet_plan( current_spec=spec, - spec=spec.copy(deep=True), + spec=spec.model_copy(deep=True), action=ApplyAction.UPDATE, ) @@ -244,4 +244,4 @@ def test_renders_ssh_hosts_change(self): def test_no_diff(self): spec = get_cloud_fleet_spec() - assert _render_fleet_spec_diff(spec, spec.copy(deep=True)) is None + assert _render_fleet_spec_diff(spec, spec.model_copy(deep=True)) is None diff --git a/src/tests/_internal/cli/services/configurators/test_profile.py b/src/tests/_internal/cli/services/configurators/test_profile.py index d3c363c0f2..796b459b46 100644 --- a/src/tests/_internal/cli/services/configurators/test_profile.py +++ b/src/tests/_internal/cli/services/configurators/test_profile.py @@ -1,10 +1,13 @@ import argparse from typing import List, Tuple +import pytest + from dstack._internal.cli.services.profile import ( apply_profile_args, register_profile_args, ) +from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.profiles import Profile, ProfileRetry, SpotPolicy @@ -12,67 +15,74 @@ class TestProfileArgs: def test_empty(self): profile = Profile(name="test") modified, _ = apply_args(profile, []) - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_profile_name(self): profile = Profile(name="test") modified, args = apply_args(profile, ["--profile", "test2"]) - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() assert args.profile == "test2" def test_max_price(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["--max-price", "0.5"]) profile.max_price = 0.5 - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_max_duration(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["--max-duration", "1h"]) profile.max_duration = 3600 - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_backends(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["-b", "gcp", "--backend", "aws"]) - profile.backends = ["gcp", "aws"] - assert profile.dict() == modified.dict() + # `BackendType`, not `str`: the args are assigned onto the model without validation, so + # anything but the declared type makes the serializer warn. + profile.backends = [BackendType.GCP, BackendType.AWS] + assert profile.model_dump() == modified.model_dump() + + def test_backends_rejects_unknown_name(self, capsys: pytest.CaptureFixture): + with pytest.raises(SystemExit): + apply_args(Profile(name="test"), ["-b", "quantum-cloud-9000"]) + assert "invalid BackendType value: 'quantum-cloud-9000'" in capsys.readouterr().err def test_spot_policy_spot(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["--spot"]) profile.spot_policy = SpotPolicy.SPOT - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_spot_policy_on_demand(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["--on-demand"]) profile.spot_policy = SpotPolicy.ONDEMAND - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_retry(self): profile = Profile(name="test", retry=None) modified, _ = apply_args(profile, ["--retry"]) profile.retry = True - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_no_retry(self): profile = Profile(name="test", retry=None) modified, _ = apply_args(profile, ["--no-retry"]) profile.retry = False - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def test_retry_duration(self): profile = Profile(name="test") modified, _ = apply_args(profile, ["--retry-duration", "1h"]) profile.retry = ProfileRetry(on_events=None, duration="1h") - assert profile.dict() == modified.dict() + assert profile.model_dump() == modified.model_dump() def apply_args(profile: Profile, args: List[str]) -> Tuple[Profile, argparse.Namespace]: parser = argparse.ArgumentParser() register_profile_args(parser) - profile = profile.copy(deep=True) # to avoid modifying the original profile + profile = profile.model_copy(deep=True) # to avoid modifying the original profile parsed_args = parser.parse_args(args) apply_profile_args(parsed_args, profile) return profile, parsed_args diff --git a/src/tests/_internal/cli/services/configurators/test_run.py b/src/tests/_internal/cli/services/configurators/test_run.py index 6bc22b9703..c0d468feaf 100644 --- a/src/tests/_internal/cli/services/configurators/test_run.py +++ b/src/tests/_internal/cli/services/configurators/test_run.py @@ -38,7 +38,7 @@ def apply_args( configurator_class = get_run_configurator_class(conf.type) configurator = configurator_class(Mock()) configurator.register_args(parser) - conf = conf.copy(deep=True) # to avoid modifying the original configuration + conf = conf.model_copy(deep=True) # to avoid modifying the original configuration parsed_args = parser.parse_args(args) configurator.apply_args(conf, parsed_args) return conf, parsed_args @@ -46,8 +46,8 @@ def apply_args( def test_env(self): conf = TaskConfiguration(commands=["whoami"]) modified, args = self.apply_args(conf, ["-e", "A=1", "--env", "B=2"]) - conf.env = Env.parse_obj({"A": "1", "B": "2"}) - assert modified.dict() == conf.dict() + conf.env = Env.model_validate({"A": "1", "B": "2"}) + assert modified.model_dump() == conf.model_dump() def test_ports(self): conf = TaskConfiguration(commands=["whoami"]) @@ -56,7 +56,7 @@ def test_ports(self): PortMapping(local_port=80, container_port=80), PortMapping(local_port=8080, container_port=8080), ] - assert modified.dict() == conf.dict() + assert modified.model_dump() == conf.model_dump() def test_container_ports_conflict(self): conf = TaskConfiguration(commands=["whoami"]) @@ -64,10 +64,10 @@ def test_container_ports_conflict(self): self.apply_args(conf, ["-p", "8000:80", "--port", "8001:80"]) def test_env_override(self): - conf = TaskConfiguration(commands=["whoami"], env=Env.parse_obj({"A": "0"})) + conf = TaskConfiguration(commands=["whoami"], env=Env.model_validate({"A": "0"})) modified, args = self.apply_args(conf, ["-e", "A=1", "--env", "B=2"]) - conf.env = Env.parse_obj({"A": "1", "B": "2"}) - assert modified.dict() == conf.dict() + conf.env = Env.model_validate({"A": "1", "B": "2"}) + assert modified.model_dump() == conf.model_dump() def test_ports_override(self): conf = TaskConfiguration(commands=["whoami"], ports=["80"]) @@ -76,7 +76,7 @@ def test_ports_override(self): PortMapping(local_port=8000, container_port=80), PortMapping(local_port=8001, container_port=8000), ] - assert modified.dict() == conf.dict() + assert modified.model_dump() == conf.model_dump() def test_local_ports_conflict(self): conf = TaskConfiguration(commands=["whoami"], ports=["3000"]) @@ -87,7 +87,7 @@ def test_any_port(self): conf = TaskConfiguration(commands=["whoami"], ports=["8000"]) modified, args = self.apply_args(conf, ["-p", "*:8000"]) conf.ports = [PortMapping(local_port=None, container_port=8000)] - assert modified.dict() == conf.dict() + assert modified.model_dump() == conf.model_dump() def test_interpolates_env(self): conf = TaskConfiguration( @@ -96,7 +96,7 @@ def test_interpolates_env(self): username="${{ env.REGISTRY_USERNAME }}", password="${{ env.REGISTRY_PASSWORD }}", ), - env=Env.parse_obj( + env=Env.model_validate( { "REGISTRY_USERNAME": "test_user", "REGISTRY_PASSWORD": "test_password", @@ -129,7 +129,7 @@ def prepare_conf( } if docker is not None: conf_dict["docker"] = docker - return BaseRunConfiguration.parse_obj(conf_dict) + return BaseRunConfiguration.model_validate(conf_dict) def validate(self, conf: BaseRunConfiguration) -> None: BaseRunConfigurator(api_client=Mock()).validate_gpu_vendor_and_image(conf) @@ -302,7 +302,7 @@ def prepare_conf( conf_dict["image"] = image if gpu_spec is not None: conf_dict["resources"]["gpu"] = gpu_spec - return BaseRunConfiguration.parse_obj(conf_dict) + return BaseRunConfiguration.model_validate(conf_dict) def validate(self, conf: BaseRunConfiguration) -> None: # validate_gpu_vendor_and_image sets GPU vendor if not set diff --git a/src/tests/_internal/cli/services/presets/test_agent.py b/src/tests/_internal/cli/services/presets/test_agent.py index 9e96c44128..e3b17396d0 100644 --- a/src/tests/_internal/cli/services/presets/test_agent.py +++ b/src/tests/_internal/cli/services/presets/test_agent.py @@ -780,7 +780,7 @@ def test_loads_interrupted_session(self, tmp_path, monkeypatch): "status": "interrupted", "claude_session_id": "sid-1", "debug": True, - "created_at": "2026-07-20T10:00:00+00:00", + "created_at": "2026-07-20T10:00:00Z", }, ) diff --git a/src/tests/_internal/cli/services/presets/test_apply.py b/src/tests/_internal/cli/services/presets/test_apply.py index 3eae3cf69d..919d2c02f9 100644 --- a/src/tests/_internal/cli/services/presets/test_apply.py +++ b/src/tests/_internal/cli/services/presets/test_apply.py @@ -51,7 +51,7 @@ def test_exact_request_matches_repo_and_client_facing_name(self): _validate_preset_matches(matching, configuration=configuration) with pytest.raises(CLIError, match="does not serve repo"): _validate_preset_matches( - matching.copy(update={"model": "other/repo"}), + matching.model_copy(update={"model": "other/repo"}), configuration=configuration, ) diff --git a/src/tests/_internal/cli/services/presets/test_create.py b/src/tests/_internal/cli/services/presets/test_create.py index e87e589f58..59f11968a2 100644 --- a/src/tests/_internal/cli/services/presets/test_create.py +++ b/src/tests/_internal/cli/services/presets/test_create.py @@ -315,7 +315,9 @@ async def run_agent(**kwargs): assert kwargs["agent_session"] is agent_session assert (session_path / "prompt.md").is_file() return PresetAgentProcessOutput( - report_data=json.loads(get_successful_preset_report(creation_context.run).json()) + report_data=json.loads( + get_successful_preset_report(creation_context.run).model_dump_json() + ) ) monkeypatch.setattr( @@ -574,7 +576,9 @@ async def test_resume_uses_saved_claude_session(self, creation_context, monkeypa async def run_agent(**kwargs): captured.update(kwargs) return PresetAgentProcessOutput( - report_data=json.loads(get_successful_preset_report(creation_context.run).json()) + report_data=json.loads( + get_successful_preset_report(creation_context.run).model_dump_json() + ) ) monkeypatch.setattr( @@ -608,7 +612,9 @@ async def test_pins_user_prompt_on_create(self, creation_context, monkeypatch, t async def run_agent(**kwargs): captured.update(kwargs) return PresetAgentProcessOutput( - report_data=json.loads(get_successful_preset_report(creation_context.run).json()) + report_data=json.loads( + get_successful_preset_report(creation_context.run).model_dump_json() + ) ) monkeypatch.setattr( @@ -649,7 +655,9 @@ async def test_resume_keeps_the_pinned_user_prompt( async def run_agent(**kwargs): captured.update(kwargs) return PresetAgentProcessOutput( - report_data=json.loads(get_successful_preset_report(creation_context.run).json()) + report_data=json.loads( + get_successful_preset_report(creation_context.run).model_dump_json() + ) ) monkeypatch.setattr( @@ -756,7 +764,9 @@ def test_finalizes_a_detached_session(self, creation_context, monkeypatch, tmp_p async def fake_attach(**kwargs): return PresetAgentProcessOutput( - report_data=json.loads(get_successful_preset_report(creation_context.run).json()) + report_data=json.loads( + get_successful_preset_report(creation_context.run).model_dump_json() + ) ) monkeypatch.setattr( diff --git a/src/tests/_internal/cli/services/presets/test_output.py b/src/tests/_internal/cli/services/presets/test_output.py index 7e76dbe063..49cca63378 100644 --- a/src/tests/_internal/cli/services/presets/test_output.py +++ b/src/tests/_internal/cli/services/presets/test_output.py @@ -110,19 +110,21 @@ def test_sorts_presets_and_sessions_newest_first(self, monkeypatch): buffer = StringIO() monkeypatch.setattr(output_module, "console", plain_console(buffer, width=200)) old = get_preset() - new = old.copy(update={"id": "11aa22bb", "created_at": old.created_at + timedelta(days=2)}) + new = old.model_copy( + update={"id": "11aa22bb", "created_at": old.created_at + timedelta(days=2)} + ) sessions = [ { "id": "aaaaaaaa", "status": "interrupted", "model": old.base, - "created_at": "2026-07-01T00:00:00+00:00", + "created_at": "2026-07-01T00:00:00Z", }, { "id": "bbbbbbbb", "status": "interrupted", "model": old.base, - "created_at": "2026-07-02T00:00:00+00:00", + "created_at": "2026-07-02T00:00:00Z", }, ] diff --git a/src/tests/_internal/cli/services/presets/test_store.py b/src/tests/_internal/cli/services/presets/test_store.py index a5b66cfe74..dcf6f05e53 100644 --- a/src/tests/_internal/cli/services/presets/test_store.py +++ b/src/tests/_internal/cli/services/presets/test_store.py @@ -38,7 +38,7 @@ def test_saving_same_id_overwrites_existing_preset(self, tmp_path: Path): preset = get_preset() store.save(preset) - updated = preset.copy(update={"base": "Qwen/Another-Model", "context_length": 16384}) + updated = preset.model_copy(update={"base": "Qwen/Another-Model", "context_length": 16384}) store.save(updated) assert store.get(preset.id) == updated @@ -67,7 +67,7 @@ def test_migrates_legacy_layout_and_archives_on_delete(self, tmp_path: Path): def test_skips_invalid_preset_on_list_but_keeps_it_deletable(self, tmp_path: Path, capsys): store = PresetStore(tmp_path / "presets") - valid = get_preset().copy(update={"id": "01234567"}) + valid = get_preset().model_copy(update={"id": "01234567"}) store.save(valid) path = store.save(get_preset()) data = yaml.safe_load(path.read_text()) @@ -168,9 +168,9 @@ def test_rejects_missing_and_empty_prompt_files(self, tmp_path: Path): class TestPresetNames: def test_finds_and_detaches_names(self, tmp_path: Path): store = PresetStore(tmp_path / "presets") - named = get_preset().copy(update={"name": "qwen"}) + named = get_preset().model_copy(update={"name": "qwen"}) store.save(named) - store.save(get_preset().copy(update={"id": "01234567"})) + store.save(get_preset().model_copy(update={"id": "01234567"})) assert store.find_by_name("qwen").id == named.id assert store.find_by_name("other") is None diff --git a/src/tests/_internal/cli/services/presets/test_verify.py b/src/tests/_internal/cli/services/presets/test_verify.py index 793bcda71c..fda1790b85 100644 --- a/src/tests/_internal/cli/services/presets/test_verify.py +++ b/src/tests/_internal/cli/services/presets/test_verify.py @@ -30,11 +30,11 @@ class TestBuildVerifiedPreset: def test_successful_report_requires_benchmark(self): run = get_running_service_run() - data = get_successful_preset_report(run).dict() + data = get_successful_preset_report(run).model_dump() data.pop("benchmark") with pytest.raises(ValidationError, match="benchmark"): - AgentFinalReport.parse_obj(data) + AgentFinalReport.model_validate(data) def test_builds_portable_self_contained_preset(self): run = get_running_service_run() @@ -62,7 +62,7 @@ def test_builds_portable_self_contained_preset(self): assert preset.created_at == created_at assert preset.service.name is None assert preset.service.gateway is None - assert all(getattr(preset.service, field) is None for field in ProfileParams.__fields__) + assert all(getattr(preset.service, field) is None for field in ProfileParams.model_fields) assert isinstance(preset.service.env["LICENSE"], EnvSentinel) assert preset.service.env["TOKENIZERS_PARALLELISM"] == "false" assert preset.service.resources.gpu.vendor.value == "nvidia" @@ -73,7 +73,7 @@ def test_builds_portable_self_contained_preset(self): def test_rejects_variant_for_exact_model_request(self): run = get_running_service_run() - report = get_successful_preset_report(run).copy(update={"model": "other/model"}) + report = get_successful_preset_report(run).model_copy(update={"model": "other/model"}) with pytest.raises(CLIError, match="changed an exact model request"): build_verified_preset( @@ -99,7 +99,7 @@ def _load(self, tmp_path, report_data, redacted_values): def test_redacts_known_secret_in_benchmark_command_instead_of_failing(self, tmp_path): run = get_running_service_run() - data = get_successful_preset_report(run).dict() + data = get_successful_preset_report(run).model_dump() data["run_id"] = str(data["run_id"]) data["benchmark"]["command"] = ( "python bench.py --header 'Authorization: Bearer sk-live-0123456789abcdef'" @@ -113,7 +113,7 @@ def test_redacts_known_secret_in_benchmark_command_instead_of_failing(self, tmp_ def test_still_rejects_unknown_bearer_token(self, tmp_path): run = get_running_service_run() - data = get_successful_preset_report(run).dict() + data = get_successful_preset_report(run).model_dump() data["run_id"] = str(data["run_id"]) data["benchmark"]["command"] = ( "curl -H 'Authorization: Bearer sk-unknown-9876543210fedcba'" @@ -126,7 +126,7 @@ def test_allows_bearer_prose_without_credential(self, tmp_path): # Regression: "(auth via DSTACK_TOKEN bearer header from env)" failed # two live sessions — the word after "bearer" is prose, not a token. run = get_running_service_run() - data = get_successful_preset_report(run).dict() + data = get_successful_preset_report(run).model_dump() data["run_id"] = str(data["run_id"]) data["benchmark"]["command"] = ( "./benchenv/bin/python bench_service.py --base $DSTACK_SERVER_URL/x" diff --git a/src/tests/_internal/cli/utils/conftest.py b/src/tests/_internal/cli/utils/conftest.py index 0f87c88bd0..a374bf9d3a 100644 --- a/src/tests/_internal/cli/utils/conftest.py +++ b/src/tests/_internal/cli/utils/conftest.py @@ -7,7 +7,9 @@ @pytest.fixture def image_config_mock(monkeypatch: pytest.MonkeyPatch) -> ImageConfig: - image_config = ImageConfig.parse_obj({"User": None, "Entrypoint": None, "Cmd": ["/bin/bash"]}) + image_config = ImageConfig.model_validate( + {"User": None, "Entrypoint": None, "Cmd": ["/bin/bash"]} + ) monkeypatch.setattr( "dstack._internal.server.services.jobs.configurators.base._get_image_config", Mock(return_value=image_config), diff --git a/src/tests/_internal/core/backends/jarvislabs/test_compute.py b/src/tests/_internal/core/backends/jarvislabs/test_compute.py index e9b055cd21..10ffae269f 100644 --- a/src/tests/_internal/core/backends/jarvislabs/test_compute.py +++ b/src/tests/_internal/core/backends/jarvislabs/test_compute.py @@ -384,7 +384,7 @@ def test_terminate_instance_deletes_created_ssh_keys(): compute = _compute() backend_data = JarvisLabsInstanceBackendData( ssh_key_ids=["ssh-key-id-1", "ssh-key-id-2"] - ).json() + ).model_dump_json() compute.terminate_instance("123", "india-noida-01", backend_data) diff --git a/src/tests/_internal/core/backends/slurm/test_configurator.py b/src/tests/_internal/core/backends/slurm/test_configurator.py index bf576a80e6..c3b2895f01 100644 --- a/src/tests/_internal/core/backends/slurm/test_configurator.py +++ b/src/tests/_internal/core/backends/slurm/test_configurator.py @@ -36,8 +36,8 @@ def test_without_creds_strips_sensitive_fields(self): cluster = config.clusters[0] # The credentialless cluster config exposes only non-sensitive fields; connection # details and the private key must not be present at all. - assert set(cluster.__fields__) == {"name", "gpu_partitions", "cpu_partitions"} - rendered = config.json() + assert set(type(cluster).model_fields) == {"name", "gpu_partitions", "cpu_partitions"} + rendered = config.model_dump_json() for secret in (PRIVATE_KEY, HOSTNAME, str(PORT), USER): assert secret not in rendered diff --git a/src/tests/_internal/core/backends/verda/test_compute.py b/src/tests/_internal/core/backends/verda/test_compute.py index b8ae8f494d..d7ddad065c 100644 --- a/src/tests/_internal/core/backends/verda/test_compute.py +++ b/src/tests/_internal/core/backends/verda/test_compute.py @@ -354,7 +354,7 @@ def test_terminate_instance_deletes_startup_script(self): backend_data = VerdaInstanceBackendData( startup_script_id="script-id", ssh_key_ids=["ssh-key-id-1", "ssh-key-id-2"], - ).json() + ).model_dump_json() compute.terminate_instance("instance-id", "FIN-01", backend_data) @@ -369,7 +369,7 @@ def test_terminate_instance_still_deletes_script_when_instance_is_missing(self): backend_data = VerdaInstanceBackendData( startup_script_id="script-id", ssh_key_ids=["ssh-key-id-1"], - ).json() + ).model_dump_json() compute.terminate_instance("instance-id", "FIN-01", backend_data) @@ -385,7 +385,7 @@ def test_terminate_instance_retries_on_script_delete_error(self): backend_data = VerdaInstanceBackendData( startup_script_id="script-id", ssh_key_ids=["ssh-key-id-1"], - ).json() + ).model_dump_json() with pytest.raises(APIException): compute.terminate_instance("instance-id", "FIN-01", backend_data) @@ -399,7 +399,7 @@ def test_terminate_instance_retries_on_ssh_key_delete_error(self): backend_data = VerdaInstanceBackendData( startup_script_id="script-id", ssh_key_ids=["ssh-key-id-1"], - ).json() + ).model_dump_json() with pytest.raises(APIException): compute.terminate_instance("instance-id", "FIN-01", backend_data) diff --git a/src/tests/_internal/core/models/test_configurations.py b/src/tests/_internal/core/models/test_configurations.py index 04095777ef..98f6ac2b5b 100644 --- a/src/tests/_internal/core/models/test_configurations.py +++ b/src/tests/_internal/core/models/test_configurations.py @@ -124,10 +124,12 @@ def test_conf(replicas: Any, scaling: Optional[Any] = None): }, ) ).replicas == Range(min=0, max=10) - with pytest.raises( - ConfigurationError, - match="When you set `replicas` to a range, ensure to specify `scaling`", - ): + # `metric: rpc` is a typo, so `scaling` itself fails to validate. The config stays + # rejected, but the message is no longer the `scaling`-is-missing one: pydantic v1 ran a + # bare `root_validator` even after a field had failed, handing it a partial `values` dict + # in which `scaling` was absent. A v2 `model_validator(mode="after")` does not run at all + # once a field is invalid, so what surfaces is the actual typo — the more precise error. + with pytest.raises(ConfigurationError, match="metric"): parse_run_configuration( test_conf( "0..10", diff --git a/src/tests/_internal/core/models/test_duration.py b/src/tests/_internal/core/models/test_duration.py new file mode 100644 index 0000000000..dc74d4255f --- /dev/null +++ b/src/tests/_internal/core/models/test_duration.py @@ -0,0 +1,104 @@ +from typing import Any, Optional + +import pytest +from pydantic import TypeAdapter, ValidationError + +from dstack._internal.core.models.duration import ( + Duration, + OptionalIdleDuration, + OptionalOffableDuration, +) + + +class TestDuration: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param(90, 90, id="int"), + pytest.param("90", 90, id="numeric-string"), + pytest.param(1.9, 1, id="float-truncates"), + pytest.param("30s", 30, id="seconds"), + pytest.param("5m", 300, id="minutes"), + pytest.param("2h", 7200, id="hours"), + pytest.param("2 h", 7200, id="space-before-unit"), + pytest.param("1d", 86400, id="days"), + pytest.param("1w", 604800, id="weeks"), + ], + ) + def test_parses_seconds_and_shorthands(self, raw: Any, expected: int): + value = TypeAdapter(Duration).validate_python(raw) + + assert value == expected + assert isinstance(value, Duration) + + @pytest.mark.parametrize( + "raw", + [ + pytest.param("bogus", id="not-a-duration"), + pytest.param("2y", id="unsupported-unit"), + pytest.param("h2", id="unit-before-amount"), + pytest.param("", id="empty"), + ], + ) + def test_rejects_unparsable(self, raw: str): + with pytest.raises(ValidationError): + TypeAdapter(Duration).validate_python(raw) + + def test_serializes_as_seconds(self): + adapter = TypeAdapter(Duration) + + assert adapter.dump_python(Duration.parse("2h")) == 7200 + assert adapter.dump_json(Duration.parse("2h")) == b"7200" + + +class TestOptionalOffableDuration: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("2h", 7200, id="shorthand"), + pytest.param(7200, 7200, id="seconds"), + pytest.param("off", "off", id="off-string"), + pytest.param(False, "off", id="false-means-off"), + pytest.param(True, None, id="true-means-default"), + pytest.param(None, None, id="unspecified"), + ], + ) + def test_normalizes(self, raw: Any, expected: Any): + assert TypeAdapter(OptionalOffableDuration).validate_python(raw) == expected + + @pytest.mark.parametrize("raw", [-1, -300, "-1"]) + def test_rejects_negative(self, raw: Any): + """Unlike `OptionalIdleDuration`, there is no negative sentinel here.""" + with pytest.raises(ValidationError): + TypeAdapter(OptionalOffableDuration).validate_python(raw) + + +class TestOptionalIdleDuration: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param("5m", 300, id="shorthand"), + pytest.param(300, 300, id="seconds"), + pytest.param("off", -1, id="off-string"), + pytest.param(False, -1, id="false-means-off"), + pytest.param(True, None, id="true-means-default"), + pytest.param(None, None, id="unspecified"), + ], + ) + def test_normalizes(self, raw: Any, expected: Optional[int]): + assert TypeAdapter(OptionalIdleDuration).validate_python(raw) == expected + + @pytest.mark.parametrize( + "raw", + [ + pytest.param(-1, id="minus-one"), + pytest.param(-300, id="other-negative"), + pytest.param("-1", id="minus-one-string"), + ], + ) + def test_accepts_negative_for_backward_compatibility(self, raw: Any): + """ + `-1` is how older clients and existing stored rows spell "off", so negatives have to keep + parsing rather than being rejected the way `OptionalOffableDuration` rejects them. + """ + assert TypeAdapter(OptionalIdleDuration).validate_python(raw) < 0 diff --git a/src/tests/_internal/core/models/test_fleets.py b/src/tests/_internal/core/models/test_fleets.py index a9214f7ece..83be0cabb5 100644 --- a/src/tests/_internal/core/models/test_fleets.py +++ b/src/tests/_internal/core/models/test_fleets.py @@ -99,7 +99,7 @@ def test_parses_nodes(self, input_nodes: Any, expected_nodes: FleetNodesSpec): "type": "fleet", "nodes": input_nodes, } - configuration = FleetConfiguration.parse_obj(configuration_input) + configuration = FleetConfiguration.model_validate(configuration_input) assert configuration.nodes == expected_nodes @pytest.mark.parametrize( @@ -119,4 +119,4 @@ def test_rejects_nodes(self, input_nodes: Any): "nodes": input_nodes, } with pytest.raises(ValidationError): - FleetConfiguration.parse_obj(configuration_input) + FleetConfiguration.model_validate(configuration_input) diff --git a/src/tests/_internal/core/models/test_instances.py b/src/tests/_internal/core/models/test_instances.py index bedc708fa0..75c21b9e61 100644 --- a/src/tests/_internal/core/models/test_instances.py +++ b/src/tests/_internal/core/models/test_instances.py @@ -5,7 +5,7 @@ class TestGpu: def test_no_vendor_nvidia(self): - gpu = Gpu.parse_obj( + gpu = Gpu.model_validate( { "name": "T4", "memory_mib": 16, @@ -15,7 +15,7 @@ def test_no_vendor_nvidia(self): assert gpu.name == "T4" def test_no_vendor_tpu(self): - gpu = Gpu.parse_obj( + gpu = Gpu.model_validate( { "name": "tpu-v3", "memory_mib": 0, @@ -25,7 +25,7 @@ def test_no_vendor_tpu(self): assert gpu.name == "v3" def test_vendor_cast_to_enum(self): - gpu = Gpu.parse_obj( + gpu = Gpu.model_validate( { "vendor": "AMD", "name": "MI300X", diff --git a/src/tests/_internal/core/models/test_profiles.py b/src/tests/_internal/core/models/test_profiles.py index 246435f6f4..fc789c8251 100644 --- a/src/tests/_internal/core/models/test_profiles.py +++ b/src/tests/_internal/core/models/test_profiles.py @@ -38,7 +38,7 @@ def test_empty_list_backend_options_is_valid(self): class TestProfileInstances: def test_string_is_parsed_as_instance_name_selector(self): - profile = Profile.parse_obj({"instances": ["my-fleet-1"]}) + profile = Profile.model_validate({"instances": ["my-fleet-1"]}) assert profile.instances == [InstanceNameSelector(name="my-fleet-1")] @@ -58,12 +58,12 @@ def test_string_is_parsed_as_instance_name_selector(self): ], ) def test_object_selectors_are_parsed(self, value, expected): - profile = Profile.parse_obj({"instances": [value]}) + profile = Profile.model_validate({"instances": [value]}) assert profile.instances == [expected] def test_parses_fleet_selector_object_notation(self): - profile = Profile.parse_obj( + profile = Profile.model_validate( {"instances": [{"fleet": {"project": "main", "name": "my-fleet"}, "instance": 0}]} ) @@ -89,22 +89,22 @@ def test_parses_fleet_selector_object_notation(self): ) def test_invalid_selector_is_rejected(self, value): with pytest.raises(ValidationError): - Profile.parse_obj({"instances": [value]}) + Profile.model_validate({"instances": [value]}) def test_empty_instances_list_is_rejected(self): with pytest.raises(ValidationError): - Profile.parse_obj({"instances": []}) + Profile.model_validate({"instances": []}) class TestProfileInstancesCompatibilityExcludes: def test_excludes_unset_instances(self): profile = Profile() - assert "instances" not in profile.dict(exclude=get_profile_excludes(profile)) + assert "instances" not in profile.model_dump(exclude=get_profile_excludes(profile)) def test_preserves_configured_instances(self): profile = Profile(instances=[InstanceNameSelector(name="my-fleet-1")]) - assert profile.dict(exclude=get_profile_excludes(profile))["instances"] == [ + assert profile.model_dump(exclude=get_profile_excludes(profile))["instances"] == [ {"name": "my-fleet-1"} ] diff --git a/src/tests/_internal/core/models/test_resources.py b/src/tests/_internal/core/models/test_resources.py index 5da32ec8f2..60227deef8 100644 --- a/src/tests/_internal/core/models/test_resources.py +++ b/src/tests/_internal/core/models/test_resources.py @@ -2,7 +2,7 @@ import pytest from gpuhunt import AcceleratorVendor, CPUArchitecture -from pydantic import ValidationError, parse_obj_as +from pydantic import TypeAdapter, ValidationError from dstack._internal.core.models.resources import ( DEFAULT_CPU_COUNT, @@ -16,102 +16,107 @@ class TestMemory: def test_mb(self): - assert parse_obj_as(Memory, "512MB") == 0.5 + assert TypeAdapter(Memory).validate_python("512MB") == 0.5 def test_gb(self): - assert parse_obj_as(Memory, "16 Gb") == 16.0 + assert TypeAdapter(Memory).validate_python("16 Gb") == 16.0 def test_tb(self): - assert parse_obj_as(Memory, "1 TB ") == 1024.0 + assert TypeAdapter(Memory).validate_python("1 TB ") == 1024.0 def test_float(self): - assert parse_obj_as(Memory, 1.5) == 1.5 + assert TypeAdapter(Memory).validate_python(1.5) == 1.5 def test_int(self): - assert parse_obj_as(Memory, 1) == 1.0 + assert TypeAdapter(Memory).validate_python(1) == 1.0 def test_invalid(self): with pytest.raises(ValidationError): - parse_obj_as(Memory, "1.5xb") + TypeAdapter(Memory).validate_python("1.5xb") class TestComputeCapability: def test_str(self): - assert parse_obj_as(ComputeCapability, "3.5") == (3, 5) + assert TypeAdapter(ComputeCapability).validate_python("3.5") == (3, 5) def test_float(self): - assert parse_obj_as(ComputeCapability, 8.0) == (8, 0) + assert TypeAdapter(ComputeCapability).validate_python(8.0) == (8, 0) def test_tuple(self): - assert parse_obj_as(ComputeCapability, (7, 5)) == (7, 5) + assert TypeAdapter(ComputeCapability).validate_python((7, 5)) == (7, 5) def test_invalid_len(self): with pytest.raises(ValidationError): - parse_obj_as(ComputeCapability, "3.5.1") + TypeAdapter(ComputeCapability).validate_python("3.5.1") def test_invalid_type(self): with pytest.raises(ValidationError): - parse_obj_as(ComputeCapability, "3.x") + TypeAdapter(ComputeCapability).validate_python("3.x") class TestIntRange: def test_int(self): - assert parse_obj_as(Range[int], 1).dict() == dict(min=1, max=1) + assert Range[int].model_validate(1).model_dump() == dict(min=1, max=1) def test_exact(self): - assert parse_obj_as(Range[int], "1").dict() == dict(min=1, max=1) + assert Range[int].model_validate("1").model_dump() == dict(min=1, max=1) def test_from(self): - assert parse_obj_as(Range[int], "1..").dict() == dict(min=1, max=None) + assert Range[int].model_validate("1..").model_dump() == dict(min=1, max=None) def test_to(self): - assert parse_obj_as(Range[int], "..1").dict() == dict(min=None, max=1) + assert Range[int].model_validate("..1").model_dump() == dict(min=None, max=1) def test_invalid_range(self): with pytest.raises(ValidationError): - parse_obj_as(Range[int], "..") + Range[int].model_validate("..") def test_range_typo(self): with pytest.raises(ValidationError): - parse_obj_as(Range[int], "1...3") + Range[int].model_validate("1...3") def test_dict(self): - assert parse_obj_as(Range[int], {"min": 1, "max": 3}).dict() == dict(min=1, max=3) + assert Range[int].model_validate({"min": 1, "max": 3}).model_dump() == dict(min=1, max=3) def test_unordered(self): with pytest.raises(ValidationError): - parse_obj_as(Range[int], "3..1") + Range[int].model_validate("3..1") def test__str__(self): - assert isinstance(str(parse_obj_as(Range[int], "1")), str) + assert isinstance(str(Range[int].model_validate("1")), str) class TestMemoryRange: def test_mb(self): - assert parse_obj_as(Range[Memory], "512MB").dict() == dict(min=0.5, max=0.5) + assert Range[Memory].model_validate("512MB").model_dump() == dict(min=0.5, max=0.5) def test_from(self): - assert parse_obj_as(Range[Memory], "512MB..").dict() == dict(min=0.5, max=None) + assert Range[Memory].model_validate("512MB..").model_dump() == dict(min=0.5, max=None) def test_to(self): - assert parse_obj_as(Range[Memory], "..1 TB").dict() == dict(min=None, max=1024.0) + assert Range[Memory].model_validate("..1 TB").model_dump() == dict(min=None, max=1024.0) def test_range(self): - assert parse_obj_as(Range[Memory], "512..1 TB").dict() == dict(min=512.0, max=1024.0) + assert Range[Memory].model_validate("512..1 TB").model_dump() == dict( + min=512.0, max=1024.0 + ) def test_invalid_range(self): with pytest.raises(ValidationError): - parse_obj_as(Range[Memory], "...") + Range[Memory].model_validate("...") def test_dict(self): - assert parse_obj_as(Range[Memory], {"min": "512MB", "max": "1TB"}).dict() == dict( + assert Range[Memory].model_validate({"min": "512MB", "max": "1TB"}).model_dump() == dict( min=0.5, max=1024.0 ) class TestCPU: def test_integer(self): - assert parse_obj_as(CPUSpec, 1).dict() == {"arch": None, "count": {"min": 1, "max": 1}} + assert CPUSpec.model_validate(1).model_dump() == { + "arch": None, + "count": {"min": 1, "max": 1}, + } @pytest.mark.parametrize( ["value", "expected_arch", "expected_min", "expected_max"], @@ -129,7 +134,7 @@ def test_valid_string( expected_min: Optional[int], expected_max: Optional[int], ): - assert parse_obj_as(CPUSpec, value).dict() == { + assert CPUSpec.model_validate(value).model_dump() == { "arch": expected_arch, "count": {"min": expected_min, "max": expected_max}, } @@ -145,34 +150,36 @@ def test_valid_string( ) def test_invalid_string(self, value: str, error: str): with pytest.raises(ValidationError, match=error): - parse_obj_as(CPUSpec, value) + CPUSpec.model_validate(value) def test_range_object(self): - assert parse_obj_as(CPUSpec, Range[int](min=1, max=2)).dict() == { + assert CPUSpec.model_validate(Range[int](min=1, max=2)).model_dump() == { "arch": None, "count": {"min": 1, "max": 2}, } def test_range_dict(self): - assert parse_obj_as(CPUSpec, {"min": 1, "max": 2}).dict() == { + assert CPUSpec.model_validate({"min": 1, "max": 2}).model_dump() == { "arch": None, "count": {"min": 1, "max": 2}, } def test_valid_dict(self): - assert parse_obj_as(CPUSpec, {"arch": "ARM", "count": {"min": 1, "max": 2}}).dict() == { + assert CPUSpec.model_validate( + {"arch": "ARM", "count": {"min": 1, "max": 2}} + ).model_dump() == { "arch": CPUArchitecture.ARM, "count": {"min": 1, "max": 2}, } def test_invalid_dict(self): with pytest.raises(ValidationError): - parse_obj_as(CPUSpec, {"arch": "x86", "min": 1, "max": 2}) + CPUSpec.model_validate({"arch": "x86", "min": 1, "max": 2}) class TestGPU: def test_count(self): - assert parse_obj_as(GPUSpec, "1") == parse_obj_as(GPUSpec, {"count": 1}) + assert GPUSpec.model_validate("1") == GPUSpec.model_validate({"count": 1}) @pytest.mark.parametrize( ["value", "expected"], @@ -203,7 +210,7 @@ def test_count(self): ], ) def test_vendor_in_string_form(self, value, expected): - assert parse_obj_as(GPUSpec, value) == parse_obj_as(GPUSpec, expected) + assert GPUSpec.model_validate(value) == GPUSpec.model_validate(expected) @pytest.mark.parametrize( ["value", "expected"], @@ -218,44 +225,44 @@ def test_vendor_in_string_form(self, value, expected): ], ) def test_vendor_in_object_form(self, value, expected): - assert parse_obj_as(GPUSpec, {"vendor": value}) == parse_obj_as( - GPUSpec, {"vendor": expected} + assert GPUSpec.model_validate({"vendor": value}) == GPUSpec.model_validate( + {"vendor": expected} ) def test_name(self): - assert parse_obj_as(GPUSpec, "A100") == parse_obj_as(GPUSpec, {"name": ["A100"]}) + assert GPUSpec.model_validate("A100") == GPUSpec.model_validate({"name": ["A100"]}) def test_name_with_tpu_prefix(self): - spec = parse_obj_as(GPUSpec, "tpu-v3-2048") + spec = GPUSpec.model_validate("tpu-v3-2048") assert spec.name == ["v3-2048"] def test_memory(self): - assert parse_obj_as(GPUSpec, "16GB") == parse_obj_as(GPUSpec, {"memory": "16GB"}) + assert GPUSpec.model_validate("16GB") == GPUSpec.model_validate({"memory": "16GB"}) def test_names_count(self): - assert parse_obj_as(GPUSpec, "A10,A10G:2") == parse_obj_as( - GPUSpec, {"name": ["A10", "A10G"], "count": 2} + assert GPUSpec.model_validate("A10,A10G:2") == GPUSpec.model_validate( + {"name": ["A10", "A10G"], "count": 2} ) def test_empty_name(self): with pytest.raises(ValidationError): - parse_obj_as(GPUSpec, "A100,:2") + GPUSpec.model_validate("A100,:2") def test_empty_token(self): with pytest.raises(ValidationError): - parse_obj_as(GPUSpec, "A100:") + GPUSpec.model_validate("A100:") def test_vendor_conflict(self): with pytest.raises(ValidationError, match=r"vendor conflict"): - parse_obj_as(GPUSpec, "Nvidia:A100:2:AMD") + GPUSpec.model_validate("Nvidia:A100:2:AMD") def test_count_conflict(self): with pytest.raises(ValidationError, match=r"count conflict"): - parse_obj_as(GPUSpec, "A100:2:3") + GPUSpec.model_validate("A100:2:3") def test_memory_range(self): - assert parse_obj_as(GPUSpec, "16GB..32") == parse_obj_as( - GPUSpec, {"memory": {"min": 16, "max": 32}} + assert GPUSpec.model_validate("16GB..32") == GPUSpec.model_validate( + {"memory": {"min": 16, "max": 32}} ) diff --git a/src/tests/_internal/core/models/test_runs.py b/src/tests/_internal/core/models/test_runs.py index 973aaa9140..e0bb9fbcee 100644 --- a/src/tests/_internal/core/models/test_runs.py +++ b/src/tests/_internal/core/models/test_runs.py @@ -139,7 +139,7 @@ def test_dynamo_router_with_retry_at_profile_level_is_rejected(self): retry={"on_events": ["error"]}, ) with pytest.raises(ValidationError, match="Dynamo"): - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) def test_dynamo_router_with_retry_in_configuration_is_rejected(self): # retry can also be specified at configuration level; _merged_profile @@ -150,12 +150,12 @@ def test_dynamo_router_with_retry_in_configuration_is_rejected(self): top_level_extras={"retry": {"on_events": ["error"]}}, ) with pytest.raises(ValidationError, match="Dynamo"): - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) def test_dynamo_router_without_retry_is_accepted(self): spec = _service_run_spec_dict(router_type="dynamo", retry=None) # Should not raise: - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) def test_sglang_router_with_retry_is_accepted(self): spec = _service_run_spec_dict( @@ -163,11 +163,11 @@ def test_sglang_router_with_retry_is_accepted(self): retry={"on_events": ["error"]}, ) # SGLang services are unaffected by the validator. - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) def test_service_without_router_with_retry_is_accepted(self): spec = _service_run_spec_dict(router_type=None, retry={"on_events": ["error"]}) - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) def test_non_service_run_with_retry_is_accepted(self): # Validator is service-only. A task or dev-environment with retry @@ -184,4 +184,4 @@ def test_non_service_run_with_retry_is_accepted(self): "ssh_key_pub": "ssh-rsa AAAA...", "repo_data": {"repo_type": "virtual"}, } - RunSpec.parse_obj(spec) + RunSpec.model_validate(spec) diff --git a/src/tests/_internal/core/models/test_templates.py b/src/tests/_internal/core/models/test_templates.py index e73decbb0c..96bf67cd5e 100644 --- a/src/tests/_internal/core/models/test_templates.py +++ b/src/tests/_internal/core/models/test_templates.py @@ -16,7 +16,7 @@ class TestUITemplateParameter: def test_parses_name_parameter(self): data = {"type": "name"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -30,7 +30,7 @@ def test_parses_name_parameter(self): def test_parses_ide_parameter(self): data = {"type": "ide"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -43,7 +43,7 @@ def test_parses_ide_parameter(self): def test_parses_resources_parameter(self): data = {"type": "resources"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -56,7 +56,7 @@ def test_parses_resources_parameter(self): def test_parses_python_or_docker_parameter(self): data = {"type": "python_or_docker"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -69,7 +69,7 @@ def test_parses_python_or_docker_parameter(self): def test_parses_repo_parameter(self): data = {"type": "repo"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -82,7 +82,7 @@ def test_parses_repo_parameter(self): def test_parses_working_dir_parameter(self): data = {"type": "working_dir"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -100,7 +100,7 @@ def test_parses_env_parameter_with_all_fields(self): "name": "PASSWORD", "value": "$random-password", } - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -117,7 +117,7 @@ def test_parses_env_parameter_with_all_fields(self): def test_parses_env_parameter_with_no_optional_fields(self): data = {"type": "env"} - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -135,7 +135,7 @@ def test_parses_env_parameter_with_no_optional_fields(self): def test_rejects_unknown_parameter_type(self): data = {"type": "unknown_type"} with pytest.raises(ValidationError): - UITemplate.parse_obj( + UITemplate.model_validate( { "type": "template", "name": "t", @@ -163,7 +163,7 @@ def test_parses_desktop_ide_template(self): ], "configuration": {"type": "dev-environment"}, } - template = UITemplate.parse_obj(data) + template = UITemplate.model_validate(data) assert template.name == "desktop-ide" assert template.title == "Desktop IDE" assert ( @@ -202,7 +202,7 @@ def test_parses_web_based_ide_template(self): "probes": [{"type": "http", "url": "/healthz"}], }, } - template = UITemplate.parse_obj(data) + template = UITemplate.model_validate(data) assert template.name == "in-browser-ide" assert template.title == "In-browser IDE" assert len(template.parameters) == 6 @@ -212,7 +212,7 @@ def test_parses_web_based_ide_template(self): def test_rejects_wrong_type(self): with pytest.raises(ValidationError): - UITemplate.parse_obj( + UITemplate.model_validate( { "type": "not-a-template", "name": "t", @@ -223,7 +223,7 @@ def test_rejects_wrong_type(self): def test_rejects_missing_configuration(self): with pytest.raises(ValidationError): - UITemplate.parse_obj( + UITemplate.model_validate( { "type": "template", "name": "t", @@ -233,7 +233,7 @@ def test_rejects_missing_configuration(self): def test_rejects_missing_name(self): with pytest.raises(ValidationError): - UITemplate.parse_obj( + UITemplate.model_validate( { "type": "template", "title": "T", @@ -242,7 +242,7 @@ def test_rejects_missing_name(self): ) def test_empty_parameters_default(self): - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", @@ -253,7 +253,7 @@ def test_empty_parameters_default(self): assert template.parameters == [] def test_description_is_optional(self): - template = UITemplate.parse_obj( + template = UITemplate.model_validate( { "type": "template", "name": "t", diff --git a/src/tests/_internal/core/models/test_volumes.py b/src/tests/_internal/core/models/test_volumes.py index 3fddfb92fd..9f95b1bffe 100644 --- a/src/tests/_internal/core/models/test_volumes.py +++ b/src/tests/_internal/core/models/test_volumes.py @@ -1,5 +1,5 @@ import pytest -from pydantic import ValidationError, parse_obj_as +from pydantic import ValidationError from dstack._internal.core.models.volumes import ( InstanceMountPoint, @@ -15,8 +15,8 @@ def test_parse(self): ) def test_path_normalization(self): - assert parse_obj_as( - VolumeMountPoint, {"name": "my-vol", "path": "/path/./to///dir/"} + assert VolumeMountPoint.model_validate( + {"name": "my-vol", "path": "/path/./to///dir/"} ) == VolumeMountPoint(name="my-vol", path="/path/to/dir") @pytest.mark.parametrize("value", ["my-vol", "my-vol:/run:ro"]) @@ -26,15 +26,15 @@ def test_parse_error_invalid_format(self, value: str): def test_validation_error_empty_path(self): with pytest.raises(ValidationError, match="empty path"): - parse_obj_as(VolumeMountPoint, {"name": "vol", "path": ""}) + VolumeMountPoint.model_validate({"name": "vol", "path": ""}) def test_validation_error_rel_path(self): with pytest.raises(ValidationError, match="path must be absolute"): - parse_obj_as(VolumeMountPoint, {"name": "vol", "path": "rel/path"}) + VolumeMountPoint.model_validate({"name": "vol", "path": "rel/path"}) def test_validation_error_parent_dir(self): with pytest.raises(ValidationError, match=r"\.\. are not allowed"): - parse_obj_as(VolumeMountPoint, {"name": "vol", "path": "/path/../to"}) + VolumeMountPoint.model_validate({"name": "vol", "path": "/path/../to"}) class TestInstanceBindMountPoint: @@ -44,8 +44,8 @@ def test_parse(self): ) def test_path_normalization(self): - assert parse_obj_as( - InstanceMountPoint, {"instance_path": "/host/.//path/", "path": "/run//./path"} + assert InstanceMountPoint.model_validate( + {"instance_path": "/host/.//path/", "path": "/run//./path"} ) == InstanceMountPoint(instance_path="/host/path", path="/run/path") @pytest.mark.parametrize("value", ["/path", "/host/path:/run/path:ro"]) @@ -58,21 +58,21 @@ def test_validation_error_empty_path(self, field: str): data = {"instance_path": "/instance_path", "path": "/run_path"} data[field] = "" with pytest.raises(ValidationError, match="empty path"): - parse_obj_as(InstanceMountPoint, data) + InstanceMountPoint.model_validate(data) @pytest.mark.parametrize("field", ["instance_path", "path"]) def test_validation_error_rel_path(self, field: str): data = {"instance_path": "/instance_path", "path": "/run_path"} data[field] = "./rel/path" with pytest.raises(ValidationError, match="path must be absolute"): - parse_obj_as(InstanceMountPoint, data) + InstanceMountPoint.model_validate(data) @pytest.mark.parametrize("field", ["instance_path", "path"]) def test_validation_error_parent_dir(self, field: str): data = {"instance_path": "/instance_path", "path": "/run_path"} data[field] = "/path/../to" with pytest.raises(ValidationError, match=r"\.\. are not allowed"): - parse_obj_as(InstanceMountPoint, data) + InstanceMountPoint.model_validate(data) class TestParseMountPoint: diff --git a/src/tests/_internal/core/services/test_diff.py b/src/tests/_internal/core/services/test_diff.py index 7b243f8a63..097961f96d 100644 --- a/src/tests/_internal/core/services/test_diff.py +++ b/src/tests/_internal/core/services/test_diff.py @@ -58,36 +58,6 @@ class _CoreModelAB(_CoreModelA, _CoreModelB): {}, id="core-model-no-diff", ), - pytest.param( - _CoreModelA.__request__(a=1, b="x"), - _CoreModelA.__request__(a=1, b="y"), - {"b": ModelFieldDiff(old="x", new="y")}, - id="core-model-request", - ), - pytest.param( - _CoreModelA.__response__(a=1, b="x"), - _CoreModelA.__response__(a=1, b="y"), - {"b": ModelFieldDiff(old="x", new="y")}, - id="core-model-response", - ), - pytest.param( - _CoreModelA.__request__(a=1, b="x"), - _CoreModelA.__response__(a=1, b="y"), - {"b": ModelFieldDiff(old="x", new="y")}, - id="core-model-request-response", - ), - pytest.param( - _CoreModelA(a=1, b="x"), - _CoreModelA.__request__(a=1, b="y"), - {"b": ModelFieldDiff(old="x", new="y")}, - id="core-model-base-request", - ), - pytest.param( - _CoreModelA(a=1, b="x"), - _CoreModelA.__response__(a=1, b="y"), - {"b": ModelFieldDiff(old="x", new="y")}, - id="core-model-base-response", - ), ], ) def test_diff_models(self, old: BaseModel, new: BaseModel, expected: ModelDiff) -> None: diff --git a/src/tests/_internal/pydantic_compat/backend_factories.py b/src/tests/_internal/pydantic_compat/backend_factories.py index 7cd16ef9a2..db76c39469 100644 --- a/src/tests/_internal/pydantic_compat/backend_factories.py +++ b/src/tests/_internal/pydantic_compat/backend_factories.py @@ -6,10 +6,10 @@ Three `Text` columns are involved, one registry each: -- `BackendModel.config` holds `XStoredConfig(...).json()`, read back as the splice - `XConfig(**json.loads(config), creds=XCreds.parse_raw(auth))`. The registries below keep the +- `BackendModel.config` holds `XStoredConfig(...).model_dump_json()`, read back as the splice + `XConfig(**json.loads(config), creds=XCreds.model_validate_json(auth))`. The registries below keep the two halves apart the way the columns do, so a fixture matches one column's bytes exactly. -- `BackendModel.auth` holds `XCreds(...).json()`. +- `BackendModel.auth` holds `XCreds(...).model_dump_json()`. - `InstanceModel.backend_data` and `VolumeModel.backend_data` hold a `*BackendData` blob. """ @@ -327,17 +327,17 @@ def vultr_stored_config() -> VultrStoredConfig: def aws_creds_access_key() -> AWSCreds: - return AWSCreds.parse_obj( + return AWSCreds.model_validate( {"type": "access_key", "access_key": "AKIAIOSFODNN7EXAMPLE", "secret_key": "wJalrXUtnFEMI"} ) def aws_creds_default() -> AWSCreds: - return AWSCreds.parse_obj({"type": "default"}) + return AWSCreds.model_validate({"type": "default"}) def azure_creds_client() -> AzureCreds: - return AzureCreds.parse_obj( + return AzureCreds.model_validate( { "type": "client", "client_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", @@ -348,21 +348,21 @@ def azure_creds_client() -> AzureCreds: def azure_creds_default() -> AzureCreds: - return AzureCreds.parse_obj({"type": "default"}) + return AzureCreds.model_validate({"type": "default"}) def gcp_creds_service_account() -> GCPCreds: - return GCPCreds.parse_obj( + return GCPCreds.model_validate( {"type": "service_account", "filename": "", "data": _SERVICE_ACCOUNT_JSON} ) def gcp_creds_default() -> GCPCreds: - return GCPCreds.parse_obj({"type": "default"}) + return GCPCreds.model_validate({"type": "default"}) def oci_creds_client() -> OCICreds: - return OCICreds.parse_obj( + return OCICreds.model_validate( { "type": "client", "user": "ocid1.user.oc1..aaaaaaaadstack", @@ -375,7 +375,7 @@ def oci_creds_client() -> OCICreds: def oci_creds_default() -> OCICreds: - return OCICreds.parse_obj({"type": "default"}) + return OCICreds.model_validate({"type": "default"}) def cloudrift_creds() -> CloudRiftCreds: diff --git a/src/tests/_internal/pydantic_compat/compare.py b/src/tests/_internal/pydantic_compat/compare.py index 6096f6655d..0f69dc9abd 100644 --- a/src/tests/_internal/pydantic_compat/compare.py +++ b/src/tests/_internal/pydantic_compat/compare.py @@ -110,19 +110,11 @@ def type_map(value: Any, path: str = "", out: Union[dict, None] = None) -> dict: def class_name(value: Any) -> str: """ - The model's name with pydantic-duality's generated suffix removed. + The model's class name. - Duality names its concrete classes `XRequest` / `XResponse`, and those suffixes vanish in v2, - so leaving them in would make every line of every type map diff on the migration branch. - - Strip only for classes duality actually generated, which is what `__response__` identifies. - Plenty of models are genuinely *named* `...Request` — every gateway registry schema, for one — - and those are plain `BaseModel`, so stripping there would report `RegisterService` for a class - called `RegisterServiceRequest`. + Kept as a function because the fixtures were generated under pydantic v1, where this stripped + the `Request`/`Response` suffix pydantic-duality gave its generated classes. A model is a single + class now, so there is nothing to strip and those fixtures still match. """ cls = value if isinstance(value, type) else type(value) - name = cls.__name__ - if hasattr(cls, "__response__"): - for suffix in ("Request", "Response"): - name = name.removesuffix(suffix) - return name + return cls.__name__ diff --git a/src/tests/_internal/pydantic_compat/compat.py b/src/tests/_internal/pydantic_compat/compat.py index 94ccb78f1d..aa24e7296e 100644 --- a/src/tests/_internal/pydantic_compat/compat.py +++ b/src/tests/_internal/pydantic_compat/compat.py @@ -1,9 +1,10 @@ """ -The only place in this package allowed to branch on pydantic version. +The one place in this package that knows how each `extra` mode is spelled. -Every test here has to run unchanged on v1 and v2, which rules out touching the duality API -directly. Forbidding extra fields needs no help — `parse_obj` forbids them in both versions. -Ignoring them does: v1 spells it `X.__response__`, and v2 will spell it `validate_extra_ignore`. +Every test here was written to run unchanged on v1 and v2, which ruled out touching the duality +API directly. Forbidding extra fields needs no help — `model_validate` forbids them in both +versions. Ignoring them did: v1 spelled it with the duality response variant, and v2 spells it +`validate_extra_ignore`. Both helpers are named for the `extra` setting they apply, deliberately avoiding the word "strict": pydantic's `strict` is an unrelated axis that turns off type coercion, and a migration @@ -13,10 +14,9 @@ from typing import Any -import pydantic from pydantic import BaseModel -PYDANTIC_V1 = pydantic.VERSION.startswith("1.") +from dstack._internal.core.models.common import validate_extra_ignore def parse_forbid_extra(model: Any, data: Any) -> BaseModel: @@ -26,7 +26,7 @@ def parse_forbid_extra(model: Any, data: Any) -> BaseModel: An unknown field is an error, which is what makes `dstack apply` report a typo'd key instead of silently ignoring it. """ - return model.parse_obj(data) + return model.model_validate(data) def parse_ignore_extra(model: Any, data: Any) -> BaseModel: @@ -36,12 +36,6 @@ def parse_ignore_extra(model: Any, data: Any) -> BaseModel: Unknown fields are dropped, which is what lets an older reader survive a newer writer, so it is the behaviour the whole migration has to preserve. """ - if not hasattr(model, "__response__"): - # Plain `BaseModel` rather than `CoreModel` — the proxy and gateway schemas. Their default - # is already extra="ignore" in both pydantic versions, so `parse_obj` is the ignore path - # and there is no duality variant to reach for. - return model.parse_obj(data) - if PYDANTIC_V1: - return model.__response__.parse_obj(data) - # v2: from dstack._internal.core.models.common import validate_extra_ignore - raise NotImplementedError("wire up validate_extra_ignore when the v2 branch lands") + # Works for plain `BaseModel` too (the proxy and gateway schemas, whose default is already + # extra="ignore"): the per-call override applies to any model, not just `CoreModel`. + return validate_extra_ignore(model, data) diff --git a/src/tests/_internal/pydantic_compat/factories.py b/src/tests/_internal/pydantic_compat/factories.py index 2e3c76f8e6..f0a7e991b1 100644 --- a/src/tests/_internal/pydantic_compat/factories.py +++ b/src/tests/_internal/pydantic_compat/factories.py @@ -16,6 +16,7 @@ DevEnvironmentConfiguration, PythonVersion, ) +from dstack._internal.core.models.duration import Duration from dstack._internal.core.models.envs import Env from dstack._internal.core.models.fleets import ( Fleet, @@ -262,7 +263,10 @@ def job_spec() -> JobSpec: job_num=0, job_name="test-run-0-0", commands=["/bin/bash", "-i", "-c", "echo hi"], - env=Env.parse_obj({"A": "1"}), + # `JobSpec.env` is a plain `Dict[str, str]`, not an `Env`. v1 happened to coerce an `Env` + # into it via the mapping protocol; v2 requires a real dict, which is what production + # passes anyway (`_env()` calls `Env.as_dict()`). + env={"A": "1"}, image_name="dstackai/base:latest", requirements=requirements(), max_duration=7200, @@ -287,7 +291,7 @@ def profile() -> Profile: instance_types=["p4d.24xlarge"], reservation="test-reservation", spot_policy=SpotPolicy.AUTO, - retry=ProfileRetry(on_events=[RetryEvent.NO_CAPACITY], duration=3600), + retry=ProfileRetry(on_events=[RetryEvent.NO_CAPACITY], duration=Duration(3600)), max_duration=7200, stop_duration=300, idle_duration=600, @@ -325,7 +329,7 @@ def requirements() -> Requirements: def resources() -> Resources: - """`Resources.dict()` rewrites `cpu` for old clients — the other custom serializer.""" + """`Resources.model_dump()` rewrites `cpu` for old clients — the other custom serializer.""" return Resources( cpus=8, memory_mib=16384, @@ -354,7 +358,7 @@ def run_spec() -> RunSpec: # `image` is deliberately absent: it is mutually exclusive with `python`, and `python` # is the more valuable of the two to pin because it is a str enum fed by a YAML float. python=PythonVersion.PY311, - env=Env.parse_obj({"HF_TOKEN": "secret"}), + env=Env.model_validate({"HF_TOKEN": "secret"}), working_dir="/workflow", inactivity_duration=3600, resources=ResourcesSpec( @@ -384,7 +388,7 @@ def volume_provisioning_data() -> VolumeProvisioningData: # --- API responses ------------------------------------------------------------------- -# Returned from a router through `CustomORJSONResponse`. Chosen by greedy set cover so that +# Returned from a router through `CustomJSONResponse`. Chosen by greedy set cover so that # between them they reach every model class reachable from any response model — 129 of 129. # `run` and `instance` are absent on purpose: `run_plan` and `fleet` already reach everything # they would add. @@ -392,7 +396,7 @@ def volume_provisioning_data() -> VolumeProvisioningData: def fleet() -> Fleet: """ - The default `FleetNodesSpec` has `target == min`, which is what makes `FleetNodesSpec.dict()` + The default `FleetNodesSpec` has `target == min`, which is what makes `FleetNodesSpec.model_dump()` drop `target` — the old-client compat hack from #3066. That override becomes a `@model_serializer` in v2, so this fixture is what proves the hack survived. """ diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_request/apply_run_plan_request.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_request/apply_run_plan_request.types.json index 50646cd45b..c3225e8f74 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_request/apply_run_plan_request.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_request/apply_run_plan_request.types.json @@ -4,8 +4,7 @@ "/plan/run_spec": "RunSpec", "/plan/run_spec/configuration": "TaskConfiguration", "/plan/run_spec/configuration/env": "Env", - "/plan/run_spec/configuration/env/__root__/HF_TOKEN": "EnvSentinel", - "/plan/run_spec/configuration/max_duration": "Duration", + "/plan/run_spec/configuration/env/root/HF_TOKEN": "EnvSentinel", "/plan/run_spec/configuration/python": "PythonVersion", "/plan/run_spec/configuration/resources": "ResourcesSpec", "/plan/run_spec/configuration/resources/cpu": "CPUSpec", @@ -20,11 +19,9 @@ "/plan/run_spec/merged_profile": "Profile", "/plan/run_spec/merged_profile/backends/0": "BackendType", "/plan/run_spec/merged_profile/creation_policy": "CreationPolicy", - "/plan/run_spec/merged_profile/max_duration": "Duration", "/plan/run_spec/merged_profile/spot_policy": "SpotPolicy", "/plan/run_spec/profile": "Profile", "/plan/run_spec/profile/backends/0": "BackendType", - "/plan/run_spec/profile/max_duration": "Duration", "/plan/run_spec/profile/spot_policy": "SpotPolicy", "/plan/run_spec/repo_data": "RemoteRunRepoData" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/fleet.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/fleet.values.json index fc93513d22..f044a57c7e 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/fleet.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/fleet.values.json @@ -1,5 +1,5 @@ { - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "id": "11111111-1111-1111-1111-111111111111", "instances": [], "name": "test-fleet", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/gateway.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/gateway.values.json index 0e2961b328..0364f1826c 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/gateway.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/gateway.values.json @@ -16,7 +16,7 @@ "tags": null, "type": "gateway" }, - "created_at": "2025-03-14T09:26:53+00:00", + "created_at": "2025-03-14T09:26:53Z", "default": true, "hostname": "gateway.inference.example.com", "id": "b41d9e77-3c8a-4f21-9d6e-5a7b8c9d0e1f", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/project.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/project.values.json index 60e523ab83..63f79ddfcb 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/project.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/project.values.json @@ -44,7 +44,7 @@ ], "owner": { "active": true, - "created_at": "2025-01-08T11:02:41+00:00", + "created_at": "2025-01-08T11:02:41Z", "email": "alice@example.com", "global_role": "admin", "id": "9c2f7b31-5e4a-4d8c-9f01-2b3c4d5e6f70", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/user_with_creds.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/user_with_creds.values.json index c2778861e1..60230fa2eb 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/user_with_creds.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/user_with_creds.values.json @@ -1,6 +1,6 @@ { "active": true, - "created_at": "2025-03-14T09:26:53+00:00", + "created_at": "2025-03-14T09:26:53Z", "creds": { "token": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9" }, diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/volume.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/volume.values.json index 5a3344d2eb..e28aa99aa2 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/volume.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/api_response/volume.values.json @@ -13,12 +13,12 @@ "volume_id": null }, "cost": 42.5, - "created_at": "2025-04-02T16:41:07+00:00", + "created_at": "2025-04-02T16:41:07Z", "deleted": false, "deleted_at": null, "external": false, "id": "e7f80912-3a4b-4c5d-9e6f-708192a3b4c5", - "last_processed_at": "2025-04-02T16:41:22+00:00", + "last_processed_at": "2025-04-02T16:41:22Z", "name": "training-data", "project_name": "main", "provisioning_data": { diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.access_key.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.access_key.types.json index ed38da62cb..0a458d43c1 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.access_key.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.access_key.types.json @@ -1,4 +1,4 @@ { "/": "AWSCreds", - "/__root__": "AWSAccessKeyCreds" + "/root": "AWSAccessKeyCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.default.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.default.types.json index 361c6cc505..fa74a50d72 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.default.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/aws.default.types.json @@ -1,4 +1,4 @@ { "/": "AWSCreds", - "/__root__": "AWSDefaultCreds" + "/root": "AWSDefaultCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.client.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.client.types.json index c5fd8d1a97..1142a9d291 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.client.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.client.types.json @@ -1,4 +1,4 @@ { "/": "AzureCreds", - "/__root__": "AzureClientCreds" + "/root": "AzureClientCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.default.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.default.types.json index 387b7147bd..83b234f773 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.default.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/azure.default.types.json @@ -1,4 +1,4 @@ { "/": "AzureCreds", - "/__root__": "AzureDefaultCreds" + "/root": "AzureDefaultCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.default.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.default.types.json index b51f633e4c..f32a017c54 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.default.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.default.types.json @@ -1,4 +1,4 @@ { "/": "GCPCreds", - "/__root__": "GCPDefaultCreds" + "/root": "GCPDefaultCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.service_account.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.service_account.types.json index 5225a9485c..d211559299 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.service_account.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/gcp.service_account.types.json @@ -1,4 +1,4 @@ { "/": "GCPCreds", - "/__root__": "GCPServiceAccountCreds" + "/root": "GCPServiceAccountCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.client.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.client.types.json index 2ea26b6f8b..47adf538ae 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.client.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.client.types.json @@ -1,4 +1,4 @@ { "/": "OCICreds", - "/__root__": "OCIClientCreds" + "/root": "OCIClientCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.default.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.default.types.json index 6f460a30c0..a12b71aea8 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.default.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/backend_creds/oci.default.types.json @@ -1,4 +1,4 @@ { "/": "OCICreds", - "/__root__": "OCIDefaultCreds" + "/root": "OCIDefaultCreds" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/dev_environment.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/dev_environment.types.json index 4d2dbc17ff..1416a0625f 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/dev_environment.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/dev_environment.types.json @@ -1,7 +1,6 @@ { "/": "DevEnvironmentConfiguration", "/env": "Env", - "/inactivity_duration": "Duration", "/python": "PythonVersion", "/resources": "ResourcesSpec", "/resources/cpu": "CPUSpec", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/fleet.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/fleet.types.json index 8887ca1483..484d864ada 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/fleet.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/fleet.types.json @@ -1,7 +1,6 @@ { "/": "FleetConfiguration", "/env": "Env", - "/idle_duration": "Duration", "/nodes": "FleetNodesSpec", "/resources": "ResourcesSpec", "/resources/cpu": "CPUSpec", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/profiles.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/profiles.types.json index 93d04a9e6d..b349fc4668 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/profiles.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/profiles.types.json @@ -1,7 +1,5 @@ { "/": "ProfilesConfig", "/profiles/0": "Profile", - "/profiles/0/idle_duration": "Duration", - "/profiles/0/max_duration": "Duration", "/profiles/0/spot_policy": "SpotPolicy" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/task.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/task.types.json index a29b85f05f..7420ad346b 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/config/task.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/config/task.types.json @@ -1,8 +1,7 @@ { "/": "TaskConfiguration", "/env": "Env", - "/env/__root__/B": "EnvSentinel", - "/max_duration": "Duration", + "/env/root/B": "EnvSentinel", "/python": "PythonVersion", "/resources": "ResourcesSpec", "/resources/cpu": "CPUSpec", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.types.json index d24311bc90..20e1d318be 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.types.json @@ -3,7 +3,6 @@ "/configuration": "FleetConfiguration", "/configuration/backends/0": "BackendType", "/configuration/env": "Env", - "/configuration/idle_duration": "Duration", "/configuration/nodes": "FleetNodesSpec", "/configuration/placement": "InstanceGroupPlacement", "/configuration/resources": "ResourcesSpec", @@ -19,9 +18,7 @@ "/configuration/spot_policy": "SpotPolicy", "/merged_profile": "Profile", "/merged_profile/backends/0": "BackendType", - "/merged_profile/idle_duration": "Duration", "/merged_profile/spot_policy": "SpotPolicy", "/profile": "Profile", - "/profile/idle_duration": "Duration", "/profile/spot_policy": "SpotPolicy" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.values.json index 18cf830b07..da0793eb45 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/fleet_spec.values.json @@ -26,7 +26,7 @@ "resources": { "cpu": { "max": null, - "min": 2 + "min": 8 }, "disk": { "size": { diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/job_provisioning_data.values.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/job_provisioning_data.values.json index 46b4cac601..e0505f98d8 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/job_provisioning_data.values.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/job_provisioning_data.values.json @@ -4,6 +4,7 @@ "backend_data": "{\"boot_disk_id\": \"vol-0fe1a2b3c4d5e6f78\"}", "base_backend": null, "dockerized": true, + "gpu_driver": null, "hostname": "54.221.13.207", "instance_id": "i-0abc123def4567890", "instance_network": null, diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/profile.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/profile.types.json index d8193ccef6..56e82a12d5 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/profile.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/profile.types.json @@ -3,8 +3,6 @@ "/backends/0": "BackendType", "/backends/1": "BackendType", "/creation_policy": "CreationPolicy", - "/idle_duration": "Duration", - "/max_duration": "Duration", "/retry": "ProfileRetry", "/retry/duration": "Duration", "/retry/on_events/0": "RetryEvent", diff --git a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/run_spec.types.json b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/run_spec.types.json index e11749d6c6..2a6ae08338 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/parsing/db/run_spec.types.json +++ b/src/tests/_internal/pydantic_compat/fixtures/parsing/db/run_spec.types.json @@ -2,7 +2,7 @@ "/": "RunSpec", "/configuration": "TaskConfiguration", "/configuration/env": "Env", - "/configuration/env/__root__/B": "EnvSentinel", + "/configuration/env/root/B": "EnvSentinel", "/configuration/python": "PythonVersion", "/configuration/resources": "ResourcesSpec", "/configuration/resources/cpu": "CPUSpec", @@ -16,8 +16,6 @@ "/configuration/resources/memory/min": "Memory", "/merged_profile": "Profile", "/merged_profile/creation_policy": "CreationPolicy", - "/merged_profile/max_duration": "Duration", "/profile": "Profile", - "/profile/max_duration": "Duration", "/repo_data": "LocalRunRepoData" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/schema/configuration.json b/src/tests/_internal/pydantic_compat/fixtures/schema/configuration.json index 8d1ac83a94..a888ed2931 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/schema/configuration.json +++ b/src/tests/_internal/pydantic_compat/fixtures/schema/configuration.json @@ -1,8 +1,6 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": true, - "definitions": { - "ACMGatewayCertificateRequest": { + "$defs": { + "ACMGatewayCertificate": { "additionalProperties": false, "properties": { "arn": { @@ -11,11 +9,9 @@ "type": "string" }, "type": { + "const": "acm", "default": "acm", "description": "Certificates by AWS Certificate Manager (ACM)", - "enum": [ - "acm" - ], "title": "Type", "type": "string" } @@ -23,42 +19,67 @@ "required": [ "arn" ], - "title": "ACMGatewayCertificateRequest", + "title": "ACMGatewayCertificate", "type": "object" }, - "AWSVolumeConfigurationRequest": { + "AWSVolumeConfiguration": { "additionalProperties": false, "properties": { "auto_cleanup_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait after volume is no longer used by any job before deleting it. Defaults to keep the volume indefinitely. Use the value `off` or `-1` to disable auto-cleanup", "title": "Auto Cleanup Duration" }, "availability_zone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume availability zone", - "title": "Availability Zone", - "type": "string" + "title": "Availability Zone" }, "backend": { + "const": "aws", "default": "aws", "description": "The volume backend", - "enum": [ - "aws" - ], "title": "Backend", "type": "string" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume name", - "title": "Name", - "type": "string" + "title": "Name" }, "region": { "description": "The volume region", @@ -66,40 +87,67 @@ "type": "string" }, "size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume size. Must be specified when creating new volumes", - "title": "Size", - "type": "number" + "title": "Size" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the volume. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "volume", "default": "volume", - "enum": [ - "volume" - ], "title": "Type", "type": "string" }, "volume_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume ID. Must be specified when registering external volumes", - "title": "Volume Id", - "type": "string" + "title": "Volume Id" } }, "required": [ "region" ], - "title": "AWSVolumeConfigurationRequest", + "title": "AWSVolumeConfiguration", "type": "object" }, "AcceleratorVendor": { - "description": "An enumeration.", "enum": [ "nvidia", "amd", @@ -141,7 +189,6 @@ "type": "string" }, "CPUArchitecture": { - "description": "An enumeration.", "enum": [ "x86", "arm" @@ -149,21 +196,25 @@ "title": "CPUArchitecture", "type": "string" }, - "CPUSpecRequest": { + "CPUSpec": { "additionalProperties": false, "properties": { "arch": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/CPUArchitecture" + }, { - "$ref": "#/definitions/CPUArchitecture" + "type": "null" } ], + "default": null, "description": "The CPU architecture, one of: `x86`, `arm`" }, "count": { "anyOf": [ { - "$ref": "#/definitions/Range_int_" + "$ref": "#/$defs/Range_int_" }, { "type": "integer" @@ -176,15 +227,13 @@ "max": null, "min": 2 }, - "description": "The number of CPU cores", - "title": "Count" + "description": "The number of CPU cores" } }, - "title": "CPUSpecRequest", + "title": "CPUSpec", "type": "object" }, "CreationPolicy": { - "description": "An enumeration.", "enum": [ "reuse", "reuse-or-create" @@ -192,44 +241,81 @@ "title": "CreationPolicy", "type": "string" }, - "DevEnvironmentConfigurationRequest": { + "DevEnvironmentConfiguration": { "additionalProperties": false, "properties": { "availability_zones": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)", - "items": { - "type": "string" - }, - "title": "Availability Zones", - "type": "array" + "title": "Availability Zones" }, "backend_options": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/VastAIProfileOptions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "Backend-specific options, applied only to offers from that backend", - "items": { - "$ref": "#/definitions/VastAIProfileOptions" - }, - "title": "Backend Options", - "type": "array" + "title": "Backend Options" }, "backends": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/BackendType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)", - "items": { - "$ref": "#/definitions/BackendType" - }, - "type": "array" + "title": "Backends" }, "creation_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/CreationPolicy" + }, { - "$ref": "#/definitions/CreationPolicy" + "type": "null" } ], + "default": null, "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`" }, "docker": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`", - "title": "Docker", - "type": "boolean" + "title": "Docker" }, "dstack": { "default": false, @@ -238,21 +324,21 @@ "type": "boolean" }, "entrypoint": { - "description": "The Docker entrypoint", - "title": "Entrypoint", - "type": "string" - }, - "env": { - "allOf": [ + "anyOf": [ + { + "type": "string" + }, { - "$ref": "#/definitions/Env" + "type": "null" } ], - "default": { - "__root__": {} - }, - "description": "The mapping or the list of environment variables", - "title": "Env" + "default": null, + "description": "The Docker entrypoint", + "title": "Entrypoint" + }, + "env": { + "$ref": "#/$defs/Env", + "description": "The mapping or the list of environment variables" }, "files": { "default": [], @@ -260,7 +346,7 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/FilePathMappingRequest" + "$ref": "#/$defs/FilePathMapping" }, { "type": "string" @@ -271,19 +357,27 @@ "type": "array" }, "fleets": { - "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/EntityReferenceRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntityReference" + }, + { + "type": "string" + } + ] }, - { - "type": "string" - } - ] - }, - "title": "Fleets", - "type": "array" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", + "title": "Fleets" }, "home_dir": { "default": "/root", @@ -293,56 +387,69 @@ "ide": { "anyOf": [ { - "enum": [ - "vscode" - ], + "const": "vscode", "type": "string" }, { - "enum": [ - "cursor" - ], + "const": "cursor", "type": "string" }, { - "enum": [ - "windsurf" - ], + "const": "windsurf", "type": "string" }, { - "enum": [ - "zed" - ], + "const": "zed", "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The IDE to pre-install. Supported values include `vscode`, `cursor`, `windsurf`, and `zed`. Defaults to no IDE (SSH only)", "title": "Ide" }, "idle_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait before terminating idle instances. When the run reuses an existing fleet instance, the fleet's `idle_duration` applies. When the run provisions a new instance, the shorter of the fleet's and run's values is used. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration. Only applied for VM-based backends", "title": "Idle Duration" }, "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The name of the Docker image to run. If no `image` is specified, `dstack` uses an Ubuntu 24.04-based Docker image that comes pre-configured with `uv`, `python`, `pip`, the CUDA 13.0 runtime, InfiniBand, NCCL, and NCCL tests. It may also include provider-specific components such as EFA support on AWS. For non-Nvidia accelerators or NVidia GPUs unsupported by CUDA 13.0 (e.g. V100, P100), specify a custom Docker image.", - "title": "Image", - "type": "string" + "title": "Image" }, "inactivity_duration": { "anyOf": [ { - "enum": [ - "off" - ], + "const": "off", "type": "string" }, { @@ -353,8 +460,12 @@ }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The maximum amount of time the dev environment can be inactive (e.g., `2h`, `1d`, etc). After it elapses, the dev environment is automatically stopped. Inactivity is defined as the absence of SSH connections to the dev environment, including VS Code connections, `ssh ` shells, and attached `dstack apply` or `dstack attach` commands. Use `off` for unlimited duration. Can be updated in-place. Defaults to `off`", "title": "Inactivity Duration" }, @@ -368,72 +479,109 @@ "type": "array" }, "instance_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The cloud-specific instance types to consider for provisioning (e.g., `[g6e.24xlarge, n1-standard-4]`)", - "items": { - "type": "string" - }, - "title": "Instance Types", - "type": "array" + "title": "Instance Types" }, "instances": { - "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/InstanceNameSelectorRequest" - }, - { - "$ref": "#/definitions/InstanceHostnameSelectorRequest" - }, - { - "$ref": "#/definitions/FleetInstanceSelectorRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/InstanceNameSelector" + }, + { + "$ref": "#/$defs/InstanceHostnameSelector" + }, + { + "$ref": "#/$defs/FleetInstanceSelector" + }, + { + "type": "string" + } + ] }, - { - "minLength": 1, - "type": "string" - } - ] - }, - "minItems": 1, - "title": "Instances", - "type": "array" + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", + "title": "Instances" }, "max_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`", "title": "Max Duration" }, "max_price": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum instance price per hour, in dollars", - "exclusiveMinimum": 0.0, - "title": "Max Price", - "type": "number" + "title": "Max Price" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The run name. If not specified, a random name is generated", - "title": "Name", - "type": "string" + "title": "Name" }, "nvcc": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`", - "title": "Nvcc", - "type": "boolean" + "title": "Nvcc" }, "ports": { "default": [], @@ -450,7 +598,7 @@ "type": "string" }, { - "$ref": "#/definitions/PortMappingRequest" + "$ref": "#/$defs/PortMapping" } ] }, @@ -458,11 +606,19 @@ "type": "array" }, "priority": { - "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`", - "maximum": 100, - "minimum": 0, - "title": "Priority", - "type": "integer" + "anyOf": [ + { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`", + "title": "Priority" }, "privileged": { "default": false, @@ -471,50 +627,76 @@ "type": "boolean" }, "python": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/PythonVersion" + }, { - "$ref": "#/definitions/PythonVersion" + "type": "null" } ], + "default": null, "description": "The major version of Python. Mutually exclusive with `image` and `docker`" }, "regions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)", - "items": { - "type": "string" - }, - "title": "Regions", - "type": "array" + "title": "Regions" }, "registry_auth": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/RegistryAuth" + }, { - "$ref": "#/definitions/RegistryAuthRequest" + "type": "null" } ], - "description": "Credentials for pulling a private Docker image", - "title": "Registry Auth" + "default": null, + "description": "Credentials for pulling a private Docker image" }, "repos": { "default": [], "description": "The list of Git repos", "items": { - "$ref": "#/definitions/RepoSpecRequest" + "anyOf": [ + { + "$ref": "#/$defs/RepoSpec" + }, + { + "type": "string" + } + ] }, "title": "Repos", "type": "array" }, "reservation": { - "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", - "title": "Reservation", - "type": "string" - }, - "resources": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/ResourcesSpecRequest" + "type": "string" + }, + { + "type": "null" } ], + "default": null, + "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", + "title": "Reservation" + }, + "resources": { + "$ref": "#/$defs/ResourcesSpec", "default": { "cpu": { "max": null, @@ -543,29 +725,35 @@ }, "shm_size": null }, - "description": "The resources requirements to run the configuration", - "title": "Resources" + "description": "The resources requirements to run the configuration" }, "retry": { "anyOf": [ { - "$ref": "#/definitions/ProfileRetryRequest" + "$ref": "#/$defs/ProfileRetry" }, { "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for resubmitting the run. Defaults to `false`", "title": "Retry" }, "schedule": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/Schedule" + }, { - "$ref": "#/definitions/ScheduleRequest" + "type": "null" } ], - "description": "The schedule for starting the run at specified time", - "title": "Schedule" + "default": null, + "description": "The schedule for starting the run at specified time" }, "setup": { "default": [], @@ -576,94 +764,145 @@ "type": "array" }, "shell": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise", - "title": "Shell", - "type": "string" + "title": "Shell" }, "single_branch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services", - "title": "Single Branch", - "type": "boolean" + "title": "Single Branch" }, "spot_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SpotPolicy" + "$ref": "#/$defs/SpotPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`" }, "startup_order": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StartupOrder" + }, { - "$ref": "#/definitions/StartupOrder" + "type": "null" } ], + "default": null, "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`" }, "stop_criteria": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StopCriteria" + }, { - "$ref": "#/definitions/StopCriteria" + "type": "null" } ], + "default": null, "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`" }, "stop_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`", "title": "Stop Duration" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "dev-environment", "default": "dev-environment", - "enum": [ - "dev-environment" - ], "title": "Type", "type": "string" }, "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`", - "title": "User", - "type": "string" + "title": "User" }, "utilization_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/UtilizationPolicy" + }, { - "$ref": "#/definitions/UtilizationPolicyRequest" + "type": "null" } ], - "description": "Run termination policy based on utilization", - "title": "Utilization Policy" + "default": null, + "description": "Run termination policy based on utilization" }, "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The version of the IDE. For `windsurf`, the version is in the format `version@commit`", - "title": "Version", - "type": "string" + "title": "Version" }, "volumes": { "default": [], @@ -671,10 +910,10 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/VolumeMountPointRequest" + "$ref": "#/$defs/VolumeMountPoint" }, { - "$ref": "#/definitions/InstanceMountPointRequest" + "$ref": "#/$defs/InstanceMountPoint" }, { "type": "string" @@ -685,21 +924,29 @@ "type": "array" }, "working_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory", - "title": "Working Dir", - "type": "string" + "title": "Working Dir" } }, - "title": "DevEnvironmentConfigurationRequest", + "title": "DevEnvironmentConfiguration", "type": "object" }, - "DiskSpecRequest": { + "DiskSpec": { "additionalProperties": false, "properties": { "size": { "anyOf": [ { - "$ref": "#/definitions/Range_Memory_" + "$ref": "#/$defs/Range_Memory_" }, { "type": "integer" @@ -708,17 +955,16 @@ "type": "string" } ], - "description": "Disk size", - "title": "Size" + "description": "Disk size" } }, "required": [ "size" ], - "title": "DiskSpecRequest", + "title": "DiskSpec", "type": "object" }, - "EntityReferenceRequest": { + "EntityReference": { "additionalProperties": false, "description": "Cross-project entity reference.", "properties": { @@ -728,21 +974,30 @@ "type": "string" }, "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The project name. If unspecified, refers to the current project", - "title": "Project", - "type": "string" + "title": "Project" } }, "required": [ "name" ], - "title": "EntityReferenceRequest", + "title": "EntityReference", "type": "object" }, "Env": { "anyOf": [ { "items": { + "pattern": "^([a-zA-Z_][a-zA-Z0-9_]*)(=.*$|$)", "type": "string" }, "type": "array" @@ -754,7 +1009,7 @@ "type": "string" }, { - "$ref": "#/definitions/EnvSentinelRequest" + "$ref": "#/$defs/EnvSentinel" } ] }, @@ -762,10 +1017,10 @@ } ], "default": {}, - "description": "Env represents a mapping of process environment variables, as in environ(7).\nEnvironment values may be omitted, in that case the :class:`EnvSentinel`\nobject is used as a placeholder.\n\nTo create an instance from a `dict[str, str]` or a `list[str]` use pydantic's\n:meth:`BaseModel.parse_obj(dict | list)` method.\n\nNB: this is *NOT* a CoreModel, pydantic-duality, which is used as a base\nfor the CoreModel, doesn't play well with custom root models.", + "description": "Env represents a mapping of process environment variables, as in environ(7).\nEnvironment values may be omitted, in that case the :class:`EnvSentinel`\nobject is used as a placeholder.\n\nTo create an instance from a `dict[str, str]` or a `list[str]` use pydantic's\n:meth:`BaseModel.model_validate(dict | list)` method.\n\nNB: this is *NOT* a CoreModel. `extra` is meaningless on a root model, but\n`coerce_numbers_to_str` is not: without it `env: {PORT: 8080}` stops parsing, since\npydantic v2 does not coerce a YAML number to a str implicitly.", "title": "Env" }, - "EnvSentinelRequest": { + "EnvSentinel": { "additionalProperties": false, "properties": { "key": { @@ -776,10 +1031,10 @@ "required": [ "key" ], - "title": "EnvSentinelRequest", + "title": "EnvSentinel", "type": "object" }, - "FilePathMappingRequest": { + "FilePathMapping": { "additionalProperties": false, "properties": { "local_path": { @@ -797,41 +1052,64 @@ "local_path", "path" ], - "title": "FilePathMappingRequest", + "title": "FilePathMapping", "type": "object" }, - "FleetConfigurationRequest": { + "FleetConfiguration": { "additionalProperties": false, "properties": { "availability_zones": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)", - "items": { - "type": "string" - }, - "title": "Availability Zones", - "type": "array" + "title": "Availability Zones" }, "backend_options": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/VastAIProfileOptions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "Backend-specific options, applied only to offers from that backend", - "items": { - "$ref": "#/definitions/VastAIProfileOptions" - }, - "title": "Backend Options", - "type": "array" + "title": "Backend Options" }, "backends": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/BackendType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)", - "items": { - "$ref": "#/definitions/BackendType" - }, - "type": "array" + "title": "Backends" }, "blocks": { "anyOf": [ { - "enum": [ - "auto" - ], + "const": "auto", "type": "string" }, { @@ -844,157 +1122,225 @@ "title": "Blocks" }, "env": { - "allOf": [ - { - "$ref": "#/definitions/Env" - } - ], - "default": { - "__root__": {} - }, - "description": "The mapping or the list of environment variables", - "title": "Env" + "$ref": "#/$defs/Env", + "description": "The mapping or the list of environment variables" }, "idle_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait before terminating idle instances. Instances are not terminated if the fleet is already at `nodes.min`. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration", "title": "Idle Duration" }, "instance_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The cloud-specific instance types to consider for provisioning (e.g., `[g6e.24xlarge, n1-standard-4]`)", - "items": { - "type": "string" - }, - "title": "Instance Types", - "type": "array" + "title": "Instance Types" }, "max_price": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum instance price per hour, in dollars", - "exclusiveMinimum": 0.0, - "title": "Max Price", - "type": "number" + "title": "Max Price" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The fleet name", - "title": "Name", - "type": "string" + "title": "Name" }, "nodes": { "anyOf": [ { - "$ref": "#/definitions/FleetNodesSpecRequest" + "$ref": "#/$defs/FleetNodesSpec" }, { "type": "integer" }, { "type": "string" + }, + { + "type": "null" } ], - "description": "The number of instances", - "title": "Nodes" + "default": null, + "description": "The number of instances" }, "placement": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/InstanceGroupPlacement" + }, { - "$ref": "#/definitions/InstanceGroupPlacement" + "type": "null" } ], + "default": null, "description": "The placement of instances: `any` or `cluster`" }, "regions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)", - "items": { - "type": "string" - }, - "title": "Regions", - "type": "array" + "title": "Regions" }, "reservation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", - "title": "Reservation", - "type": "string" + "title": "Reservation" }, "resources": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/ResourcesSpec" + }, { - "$ref": "#/definitions/ResourcesSpecRequest" + "type": "null" } ], - "description": "The resources requirements", - "title": "Resources" + "default": null, + "description": "The resources requirements" }, "retry": { "anyOf": [ { - "$ref": "#/definitions/ProfileRetryRequest" + "$ref": "#/$defs/ProfileRetry" }, { "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for provisioning retry. Defaults to `false`", "title": "Retry" }, "spot_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SpotPolicy" + "$ref": "#/$defs/SpotPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`" }, "ssh_config": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SSHParamsRequest" - } + "$ref": "#/$defs/SSHParams" + }, + { + "type": "null" + } ], - "description": "The parameters for adding instances via SSH", - "title": "Ssh Config" + "default": null, + "description": "The parameters for adding instances via SSH" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "fleet", "default": "fleet", - "enum": [ - "fleet" - ], "title": "Type", "type": "string" } }, - "title": "FleetConfigurationRequest", + "title": "FleetConfiguration", "type": "object" }, - "FleetInstanceSelectorRequest": { + "FleetInstanceSelector": { "additionalProperties": false, "properties": { "fleet": { "anyOf": [ { - "$ref": "#/definitions/EntityReferenceRequest" + "$ref": "#/$defs/EntityReference" }, { - "minLength": 1, "type": "string" } ], - "description": "The fleet reference. For fleets owned by the current project, specify the fleet name. For a fleet from another project, specify `/` or an object with `project` and `name`.", - "title": "Fleet" + "description": "The fleet reference. For fleets owned by the current project, specify the fleet name. For a fleet from another project, specify `/` or an object with `project` and `name`." }, "instance": { "description": "The fleet instance number", @@ -1007,16 +1353,24 @@ "fleet", "instance" ], - "title": "FleetInstanceSelectorRequest", + "title": "FleetInstanceSelector", "type": "object" }, - "FleetNodesSpecRequest": { + "FleetNodesSpec": { "additionalProperties": false, "properties": { "max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum number of instances allowed in the fleet. Unlimited if not specified", - "title": "Max", - "type": "integer" + "title": "Max" }, "min": { "description": "The minimum number of instances to maintain in the fleet", @@ -1033,42 +1387,67 @@ "min", "target" ], - "title": "FleetNodesSpecRequest", + "title": "FleetNodesSpec", "type": "object" }, - "GCPVolumeConfigurationRequest": { + "GCPVolumeConfiguration": { "additionalProperties": false, "properties": { "auto_cleanup_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait after volume is no longer used by any job before deleting it. Defaults to keep the volume indefinitely. Use the value `off` or `-1` to disable auto-cleanup", "title": "Auto Cleanup Duration" }, "availability_zone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume availability zone", - "title": "Availability Zone", - "type": "string" + "title": "Availability Zone" }, "backend": { + "const": "gcp", "default": "gcp", "description": "The volume backend", - "enum": [ - "gcp" - ], "title": "Backend", "type": "string" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume name", - "title": "Name", - "type": "string" + "title": "Name" }, "region": { "description": "The volume region", @@ -1076,51 +1455,95 @@ "type": "string" }, "size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume size. Must be specified when creating new volumes", - "title": "Size", - "type": "number" + "title": "Size" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the volume. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "volume", "default": "volume", - "enum": [ - "volume" - ], "title": "Type", "type": "string" }, "volume_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The volume ID. Must be specified when registering external volumes", - "title": "Volume Id", - "type": "string" + "title": "Volume Id" } }, "required": [ "region" ], - "title": "GCPVolumeConfigurationRequest", + "title": "GCPVolumeConfiguration", "type": "object" }, - "GPUSpecRequest": { + "GPUSpec": { "additionalProperties": false, "properties": { "compute_capability": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The minimum compute capability of the GPU (e.g., `7.5`)", - "items": {}, - "title": "Compute Capability", - "type": "array" + "title": "Compute Capability" }, "count": { "anyOf": [ { - "$ref": "#/definitions/Range_int_" + "$ref": "#/$defs/Range_int_" }, { "type": "integer" @@ -1133,97 +1556,113 @@ "max": null, "min": 1 }, - "description": "The number of GPUs", - "title": "Count" + "description": "The number of GPUs" }, "memory": { "anyOf": [ { - "$ref": "#/definitions/Range_Memory_" + "$ref": "#/$defs/Range_Memory_" }, { "type": "integer" }, { "type": "string" + }, + { + "type": "null" } ], - "description": "The RAM size (e.g., `16GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)", - "title": "Memory" + "default": null, + "description": "The RAM size (e.g., `16GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)" }, "name": { "anyOf": [ { + "items": { + "type": "string" + }, "type": "array" }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The name of the GPU (e.g., `A100` or `H100`)", - "items": { - "type": "string" - }, "title": "Name" }, "total_memory": { "anyOf": [ { - "$ref": "#/definitions/Range_Memory_" + "$ref": "#/$defs/Range_Memory_" }, { "type": "integer" }, { "type": "string" + }, + { + "type": "null" } ], - "description": "The total RAM size (e.g., `32GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)", - "title": "Total Memory" + "default": null, + "description": "The total RAM size (e.g., `32GB`). Can be set to a range (e.g. `16GB..`, or `16GB..80GB`)" }, "vendor": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/AcceleratorVendor" + }, { - "$ref": "#/definitions/AcceleratorVendor" + "type": "null" } ], + "default": null, "description": "The vendor of the GPU/accelerator, one of: `nvidia`, `amd`, `google` (alias: `tpu`), `intel`" } }, - "title": "GPUSpecRequest", + "title": "GPUSpec", "type": "object" }, - "GatewayConfigurationRequest": { + "GatewayConfiguration": { "additionalProperties": false, "properties": { "backend": { - "allOf": [ - { - "$ref": "#/definitions/BackendType" - } - ], + "$ref": "#/$defs/BackendType", "description": "The gateway backend" }, "certificate": { - "default": { - "type": "lets-encrypt" - }, - "description": "The SSL certificate configuration. Set to `null` to disable. Defaults to `type: lets-encrypt`", - "discriminator": { - "mapping": { - "acm": "#/definitions/ACMGatewayCertificateRequest", - "lets-encrypt": "#/definitions/LetsEncryptGatewayCertificateRequest" - }, - "propertyName": "type" - }, - "oneOf": [ + "anyOf": [ { - "$ref": "#/definitions/LetsEncryptGatewayCertificateRequest" + "discriminator": { + "mapping": { + "acm": "#/$defs/ACMGatewayCertificate", + "lets-encrypt": "#/$defs/LetsEncryptGatewayCertificate" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/LetsEncryptGatewayCertificate" + }, + { + "$ref": "#/$defs/ACMGatewayCertificate" + } + ] }, { - "$ref": "#/definitions/ACMGatewayCertificateRequest" + "type": "null" } ], + "default": { + "type": "lets-encrypt" + }, + "description": "The SSL certificate configuration. Set to `null` to disable. Defaults to `type: lets-encrypt`", "title": "Certificate" }, "default": { @@ -1233,20 +1672,44 @@ "type": "boolean" }, "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The gateway wildcard domain name, e.g. `example.com`. Service domain names are constructed as `./`. `dstack` will use IP addresses from this network for communication between hosts. If not specified, `dstack` will use IPs from the first found internal network.", - "title": "Network", - "type": "string" + "title": "Network" }, "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The SSH port to connect to", - "title": "Port", - "type": "integer" + "title": "Port" }, "proxy_jump": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SSHProxyParamsRequest" + "$ref": "#/$defs/SSHProxyParams" + }, + { + "type": "null" } ], - "description": "The SSH proxy configuration for all hosts", - "title": "Proxy Jump" + "default": null, + "description": "The SSH proxy configuration for all hosts" }, "ssh_key": { - "$ref": "#/definitions/SSHKeyRequest" + "anyOf": [ + { + "$ref": "#/$defs/SSHKey" + }, + { + "type": "null" + } + ], + "default": null }, "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The user to log in with on all hosts", - "title": "User", - "type": "string" + "title": "User" } }, "required": [ "hosts" ], - "title": "SSHParamsRequest", + "title": "SSHParams", "type": "object" }, - "SSHProxyParamsRequest": { + "SSHProxyParams": { "additionalProperties": false, "properties": { "hostname": { @@ -2437,12 +3309,28 @@ "type": "string" }, "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The SSH port of proxy host", - "title": "Port", - "type": "integer" + "title": "Port" }, "ssh_key": { - "$ref": "#/definitions/SSHKeyRequest" + "anyOf": [ + { + "$ref": "#/$defs/SSHKey" + }, + { + "type": "null" + } + ], + "default": null }, "user": { "description": "The user to log in with for proxy host", @@ -2455,31 +3343,43 @@ "user", "identity_file" ], - "title": "SSHProxyParamsRequest", + "title": "SSHProxyParams", "type": "object" }, - "ScalingSpecRequest": { + "ScalingSpec": { "additionalProperties": false, "properties": { "metric": { + "const": "rps", "description": "The target metric to track. Currently, the only supported value is `rps` (meaning requests per second)", - "enum": [ - "rps" - ], "title": "Metric", "type": "string" }, "scale_down_delay": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], "default": 600, "description": "The minimum time, in seconds, between a scaling event and the next scale-down decision. Used to prevent overly frequent scaling", - "title": "Scale Down Delay", - "type": "integer" + "title": "Scale Down Delay" }, "scale_up_delay": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], "default": 300, "description": "The minimum time, in seconds, between a scaling event and the next scale-up decision. Used to prevent overly frequent scaling", - "title": "Scale Up Delay", - "type": "integer" + "title": "Scale Up Delay" }, "target": { "description": "The target value of the metric. The number of replicas is calculated based on this number and automatically adjusts (scales up or down) as this metric changes", @@ -2488,19 +3388,30 @@ "type": "number" }, "window": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The time window used to calculate requests per second. Allowed values: `30s`, `60s`, `300s`. Defaults to `60s`", - "title": "Window", - "type": "integer" + "title": "Window" } }, "required": [ "metric", "target" ], - "title": "ScalingSpecRequest", + "title": "ScalingSpec", "type": "object" }, - "ScheduleRequest": { + "Schedule": { "additionalProperties": false, "properties": { "cron": { @@ -2522,10 +3433,10 @@ "required": [ "cron" ], - "title": "ScheduleRequest", + "title": "Schedule", "type": "object" }, - "ServiceConfigurationRequest": { + "ServiceConfiguration": { "additionalProperties": false, "properties": { "auth": { @@ -2535,27 +3446,52 @@ "type": "boolean" }, "availability_zones": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)", - "items": { - "type": "string" - }, - "title": "Availability Zones", - "type": "array" + "title": "Availability Zones" }, "backend_options": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/VastAIProfileOptions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "Backend-specific options, applied only to offers from that backend", - "items": { - "$ref": "#/definitions/VastAIProfileOptions" - }, - "title": "Backend Options", - "type": "array" + "title": "Backend Options" }, "backends": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/BackendType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)", - "items": { - "$ref": "#/definitions/BackendType" - }, - "type": "array" + "title": "Backends" }, "commands": { "default": [], @@ -2567,17 +3503,29 @@ "type": "array" }, "creation_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/CreationPolicy" + }, { - "$ref": "#/definitions/CreationPolicy" + "type": "null" } ], + "default": null, "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`" }, "docker": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`", - "title": "Docker", - "type": "boolean" + "title": "Docker" }, "dstack": { "default": false, @@ -2586,21 +3534,21 @@ "type": "boolean" }, "entrypoint": { - "description": "The Docker entrypoint", - "title": "Entrypoint", - "type": "string" - }, - "env": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/Env" + "type": "string" + }, + { + "type": "null" } ], - "default": { - "__root__": {} - }, - "description": "The mapping or the list of environment variables", - "title": "Env" + "default": null, + "description": "The Docker entrypoint", + "title": "Entrypoint" + }, + "env": { + "$ref": "#/$defs/Env", + "description": "The mapping or the list of environment variables" }, "files": { "default": [], @@ -2608,7 +3556,7 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/FilePathMappingRequest" + "$ref": "#/$defs/FilePathMapping" }, { "type": "string" @@ -2619,19 +3567,27 @@ "type": "array" }, "fleets": { - "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/EntityReferenceRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntityReference" + }, + { + "type": "string" + } + ] }, - { - "type": "string" - } - ] - }, - "title": "Fleets", - "type": "array" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", + "title": "Fleets" }, "gateway": { "anyOf": [ @@ -2639,12 +3595,16 @@ "type": "boolean" }, { - "$ref": "#/definitions/EntityReferenceRequest" + "$ref": "#/$defs/EntityReference" }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The name of the gateway. Specify boolean `false` to run without a gateway. Specify boolean `true` to run with the default gateway. Omit to run with the default gateway if there is one, or without a gateway otherwise", "title": "Gateway" }, @@ -2659,125 +3619,187 @@ "type": "boolean" }, { - "enum": [ - "auto" - ], + "const": "auto", "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "Enable HTTPS if running with a gateway. Set to `auto` to determine automatically based on gateway configuration. Defaults to `true`", "title": "Https" }, "idle_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait before terminating idle instances. When the run reuses an existing fleet instance, the fleet's `idle_duration` applies. When the run provisions a new instance, the shorter of the fleet's and run's values is used. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration. Only applied for VM-based backends", "title": "Idle Duration" }, "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The name of the Docker image to run. If no `image` is specified, `dstack` uses an Ubuntu 24.04-based Docker image that comes pre-configured with `uv`, `python`, `pip`, the CUDA 13.0 runtime, InfiniBand, NCCL, and NCCL tests. It may also include provider-specific components such as EFA support on AWS. For non-Nvidia accelerators or NVidia GPUs unsupported by CUDA 13.0 (e.g. V100, P100), specify a custom Docker image.", - "title": "Image", - "type": "string" + "title": "Image" }, "instance_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The cloud-specific instance types to consider for provisioning (e.g., `[g6e.24xlarge, n1-standard-4]`)", - "items": { - "type": "string" - }, - "title": "Instance Types", - "type": "array" + "title": "Instance Types" }, "instances": { - "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/InstanceNameSelectorRequest" - }, - { - "$ref": "#/definitions/InstanceHostnameSelectorRequest" - }, - { - "$ref": "#/definitions/FleetInstanceSelectorRequest" - }, - { - "minLength": 1, - "type": "string" - } - ] - }, - "minItems": 1, - "title": "Instances", - "type": "array" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/InstanceNameSelector" + }, + { + "$ref": "#/$defs/InstanceHostnameSelector" + }, + { + "$ref": "#/$defs/FleetInstanceSelector" + }, + { + "type": "string" + } + ] + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", + "title": "Instances" }, "max_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`", "title": "Max Duration" }, "max_price": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum instance price per hour, in dollars", - "exclusiveMinimum": 0.0, - "title": "Max Price", - "type": "number" + "title": "Max Price" }, "model": { "anyOf": [ { "discriminator": { "mapping": { - "openai": "#/definitions/OpenAIChatModelRequest", - "tgi": "#/definitions/TGIChatModelRequest" + "openai": "#/$defs/OpenAIChatModel", + "tgi": "#/$defs/TGIChatModel" }, "propertyName": "format" }, "oneOf": [ { - "$ref": "#/definitions/TGIChatModelRequest" + "$ref": "#/$defs/TGIChatModel" }, { - "$ref": "#/definitions/OpenAIChatModelRequest" + "$ref": "#/$defs/OpenAIChatModel" } ] }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "Mapping of the model for the OpenAI-compatible endpoint provided by `dstack`. Can be a full model format definition or just a model name. If it's a name, the service is expected to expose an OpenAI-compatible API at the `/v1` path", "title": "Model" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The run name. If not specified, a random name is generated", - "title": "Name", - "type": "string" + "title": "Name" }, "nvcc": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`", - "title": "Nvcc", - "type": "boolean" + "title": "Nvcc" }, "port": { "anyOf": [ @@ -2791,18 +3813,26 @@ "type": "string" }, { - "$ref": "#/definitions/PortMappingRequest" + "$ref": "#/$defs/PortMapping" } ], "description": "The port the application listens on", "title": "Port" }, "priority": { + "anyOf": [ + { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`", - "maximum": 100, - "minimum": 0, - "title": "Priority", - "type": "integer" + "title": "Priority" }, "privileged": { "default": false, @@ -2811,65 +3841,92 @@ "type": "boolean" }, "probes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ProbeConfig" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The list of probes to determine service health. If `model` is set, defaults to a `/v1/chat/completions` probe. Set explicitly to override", - "items": { - "$ref": "#/definitions/ProbeConfigRequest" - }, - "title": "Probes", - "type": "array" + "title": "Probes" }, "python": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/PythonVersion" + }, { - "$ref": "#/definitions/PythonVersion" + "type": "null" } ], + "default": null, "description": "The major version of Python. Mutually exclusive with `image` and `docker`" }, "rate_limits": { "default": [], "description": "Rate limiting rules", "items": { - "$ref": "#/definitions/RateLimitRequest" + "$ref": "#/$defs/RateLimit" }, "title": "Rate Limits", "type": "array" }, "regions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)", - "items": { - "type": "string" - }, - "title": "Regions", - "type": "array" + "title": "Regions" }, "registry_auth": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/RegistryAuth" + }, { - "$ref": "#/definitions/RegistryAuthRequest" + "type": "null" } ], - "description": "Credentials for pulling a private Docker image", - "title": "Registry Auth" + "default": null, + "description": "Credentials for pulling a private Docker image" }, "replicas": { "anyOf": [ { "items": { - "$ref": "#/definitions/ReplicaGroupRequest" + "$ref": "#/$defs/ReplicaGroup" }, "type": "array" }, { - "$ref": "#/definitions/Range_int_" + "$ref": "#/$defs/Range_int_" }, { "type": "integer" }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The number of replicas or a list of replica groups. Can be an integer (e.g., `2`), a range (e.g., `0..4`), or a list of replica groups. Each replica group defines replicas with shared configuration (commands, resources, scaling). When `replicas` is a list of replica groups, top-level `scaling`, `commands`, and `resources` are not allowed and must be specified in each replica group instead. ", "title": "Replicas" }, @@ -2877,22 +3934,33 @@ "default": [], "description": "The list of Git repos", "items": { - "$ref": "#/definitions/RepoSpecRequest" + "anyOf": [ + { + "$ref": "#/$defs/RepoSpec" + }, + { + "type": "string" + } + ] }, "title": "Repos", "type": "array" }, "reservation": { - "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", - "title": "Reservation", - "type": "string" - }, - "resources": { - "allOf": [ + "anyOf": [ + { + "type": "string" + }, { - "$ref": "#/definitions/ResourcesSpecRequest" + "type": "null" } ], + "default": null, + "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", + "title": "Reservation" + }, + "resources": { + "$ref": "#/$defs/ResourcesSpec", "default": { "cpu": { "max": null, @@ -2921,47 +3989,59 @@ }, "shm_size": null }, - "description": "The resources requirements to run the configuration", - "title": "Resources" + "description": "The resources requirements to run the configuration" }, "retry": { "anyOf": [ { - "$ref": "#/definitions/ProfileRetryRequest" + "$ref": "#/$defs/ProfileRetry" }, { "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for resubmitting the run. Defaults to `false`", "title": "Retry" }, "router": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/SGLangServiceRouterConfig" + }, { - "$ref": "#/definitions/SGLangServiceRouterConfigRequest" + "type": "null" } ], - "description": "Router configuration for the service. Requires a gateway with matching router enabled. ", - "title": "Router" + "default": null, + "description": "Router configuration for the service. Requires a gateway with matching router enabled. " }, "scaling": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/ScalingSpec" + }, { - "$ref": "#/definitions/ScalingSpecRequest" + "type": "null" } ], - "description": "The auto-scaling rules. Required if `replicas` is set to a range", - "title": "Scaling" + "default": null, + "description": "The auto-scaling rules. Required if `replicas` is set to a range" }, "schedule": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/Schedule" + }, { - "$ref": "#/definitions/ScheduleRequest" + "type": "null" } ], - "description": "The schedule for starting the run at specified time", - "title": "Schedule" + "default": null, + "description": "The schedule for starting the run at specified time" }, "setup": { "default": [], @@ -2972,57 +4052,83 @@ "type": "array" }, "shell": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise", - "title": "Shell", - "type": "string" + "title": "Shell" }, "single_branch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services", - "title": "Single Branch", - "type": "boolean" + "title": "Single Branch" }, "spot_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SpotPolicy" + "$ref": "#/$defs/SpotPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`" }, "startup_order": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StartupOrder" + }, { - "$ref": "#/definitions/StartupOrder" + "type": "null" } ], + "default": null, "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`" }, "stop_criteria": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StopCriteria" + }, { - "$ref": "#/definitions/StopCriteria" + "type": "null" } ], + "default": null, "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`" }, "stop_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`", "title": "Stop Duration" }, @@ -3033,34 +4139,51 @@ "type": "boolean" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "service", "default": "service", - "enum": [ - "service" - ], "title": "Type", "type": "string" }, "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`", - "title": "User", - "type": "string" + "title": "User" }, "utilization_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/UtilizationPolicy" + }, { - "$ref": "#/definitions/UtilizationPolicyRequest" + "type": "null" } ], - "description": "Run termination policy based on utilization", - "title": "Utilization Policy" + "default": null, + "description": "Run termination policy based on utilization" }, "volumes": { "default": [], @@ -3068,10 +4191,10 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/VolumeMountPointRequest" + "$ref": "#/$defs/VolumeMountPoint" }, { - "$ref": "#/definitions/InstanceMountPointRequest" + "$ref": "#/$defs/InstanceMountPoint" }, { "type": "string" @@ -3082,19 +4205,26 @@ "type": "array" }, "working_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory", - "title": "Working Dir", - "type": "string" + "title": "Working Dir" } }, "required": [ "port" ], - "title": "ServiceConfigurationRequest", + "title": "ServiceConfiguration", "type": "object" }, "SpotPolicy": { - "description": "An enumeration.", "enum": [ "spot", "on-demand", @@ -3104,7 +4234,6 @@ "type": "string" }, "StartupOrder": { - "description": "An enumeration.", "enum": [ "any", "master-first", @@ -3114,7 +4243,6 @@ "type": "string" }, "StopCriteria": { - "description": "An enumeration.", "enum": [ "all-done", "master-done" @@ -3122,25 +4250,39 @@ "title": "StopCriteria", "type": "string" }, - "TGIChatModelRequest": { + "TGIChatModel": { "additionalProperties": false, "description": "Mapping of the model for the OpenAI-compatible endpoint.\n\nAttributes:\n type (str): The type of the model, e.g. \"chat\"\n name (str): The name of the model. This name will be used both to load model configuration from the HuggingFace Hub and in the OpenAI-compatible endpoint.\n format (str): The format of the model, e.g. \"tgi\" if the model is served with HuggingFace's Text Generation Inference.\n chat_template (Optional[str]): The custom prompt template for the model. If not specified, the default prompt template from the HuggingFace Hub configuration will be used.\n eos_token (Optional[str]): The custom end of sentence token. If not specified, the default end of sentence token from the HuggingFace Hub configuration will be used.", "properties": { "chat_template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom prompt template for the model. If not specified, the default prompt template from the HuggingFace Hub configuration will be used", - "title": "Chat Template", - "type": "string" + "title": "Chat Template" }, "eos_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom end of sentence token. If not specified, the default end of sentence token from the HuggingFace Hub configuration will be used", - "title": "Eos Token", - "type": "string" + "title": "Eos Token" }, "format": { + "const": "tgi", "description": "The serving format. Must be set to `tgi`", - "enum": [ - "tgi" - ], "title": "Format", "type": "string" }, @@ -3150,11 +4292,9 @@ "type": "string" }, "type": { + "const": "chat", "default": "chat", "description": "The type of the model", - "enum": [ - "chat" - ], "title": "Type", "type": "string" } @@ -3163,34 +4303,59 @@ "name", "format" ], - "title": "TGIChatModelRequest", + "title": "TGIChatModel", "type": "object" }, - "TaskConfigurationRequest": { + "TaskConfiguration": { "additionalProperties": false, "properties": { "availability_zones": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)", - "items": { - "type": "string" - }, - "title": "Availability Zones", - "type": "array" + "title": "Availability Zones" }, "backend_options": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/VastAIProfileOptions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "Backend-specific options, applied only to offers from that backend", - "items": { - "$ref": "#/definitions/VastAIProfileOptions" - }, - "title": "Backend Options", - "type": "array" + "title": "Backend Options" }, "backends": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/BackendType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)", - "items": { - "$ref": "#/definitions/BackendType" - }, - "type": "array" + "title": "Backends" }, "commands": { "default": [], @@ -3202,17 +4367,29 @@ "type": "array" }, "creation_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/CreationPolicy" + "$ref": "#/$defs/CreationPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`" }, "docker": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use Docker inside the container. Mutually exclusive with `image`, `python`, and `nvcc`. Overrides `privileged`", - "title": "Docker", - "type": "boolean" + "title": "Docker" }, "dstack": { "default": false, @@ -3221,21 +4398,21 @@ "type": "boolean" }, "entrypoint": { - "description": "The Docker entrypoint", - "title": "Entrypoint", - "type": "string" - }, - "env": { - "allOf": [ + "anyOf": [ + { + "type": "string" + }, { - "$ref": "#/definitions/Env" + "type": "null" } ], - "default": { - "__root__": {} - }, - "description": "The mapping or the list of environment variables", - "title": "Env" + "default": null, + "description": "The Docker entrypoint", + "title": "Entrypoint" + }, + "env": { + "$ref": "#/$defs/Env", + "description": "The mapping or the list of environment variables" }, "files": { "default": [], @@ -3243,7 +4420,7 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/FilePathMappingRequest" + "$ref": "#/$defs/FilePathMapping" }, { "type": "string" @@ -3254,19 +4431,27 @@ "type": "array" }, "fleets": { - "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/EntityReferenceRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntityReference" + }, + { + "type": "string" + } + ] }, - { - "type": "string" - } - ] - }, - "title": "Fleets", - "type": "array" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", + "title": "Fleets" }, "home_dir": { "default": "/root", @@ -3275,83 +4460,131 @@ }, "idle_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait before terminating idle instances. When the run reuses an existing fleet instance, the fleet's `idle_duration` applies. When the run provisions a new instance, the shorter of the fleet's and run's values is used. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration. Only applied for VM-based backends", "title": "Idle Duration" }, "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The name of the Docker image to run. If no `image` is specified, `dstack` uses an Ubuntu 24.04-based Docker image that comes pre-configured with `uv`, `python`, `pip`, the CUDA 13.0 runtime, InfiniBand, NCCL, and NCCL tests. It may also include provider-specific components such as EFA support on AWS. For non-Nvidia accelerators or NVidia GPUs unsupported by CUDA 13.0 (e.g. V100, P100), specify a custom Docker image.", - "title": "Image", - "type": "string" + "title": "Image" }, "instance_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The cloud-specific instance types to consider for provisioning (e.g., `[g6e.24xlarge, n1-standard-4]`)", - "items": { - "type": "string" - }, - "title": "Instance Types", - "type": "array" + "title": "Instance Types" }, "instances": { - "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/InstanceNameSelectorRequest" - }, - { - "$ref": "#/definitions/InstanceHostnameSelectorRequest" - }, - { - "$ref": "#/definitions/FleetInstanceSelectorRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/InstanceNameSelector" + }, + { + "$ref": "#/$defs/InstanceHostnameSelector" + }, + { + "$ref": "#/$defs/FleetInstanceSelector" + }, + { + "type": "string" + } + ] }, - { - "minLength": 1, - "type": "string" - } - ] - }, - "minItems": 1, - "title": "Instances", - "type": "array" + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", + "title": "Instances" }, "max_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`", "title": "Max Duration" }, "max_price": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum instance price per hour, in dollars", - "exclusiveMinimum": 0.0, - "title": "Max Price", - "type": "number" + "title": "Max Price" }, "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The run name. If not specified, a random name is generated", - "title": "Name", - "type": "string" + "title": "Name" }, "nodes": { "default": 1, @@ -3361,9 +4594,17 @@ "type": "integer" }, "nvcc": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Use image with NVIDIA CUDA Compiler (NVCC) included. Mutually exclusive with `image` and `docker`", - "title": "Nvcc", - "type": "boolean" + "title": "Nvcc" }, "ports": { "default": [], @@ -3380,7 +4621,7 @@ "type": "string" }, { - "$ref": "#/definitions/PortMappingRequest" + "$ref": "#/$defs/PortMapping" } ] }, @@ -3388,11 +4629,19 @@ "type": "array" }, "priority": { + "anyOf": [ + { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The priority of the run, an integer between `0` and `100`. `dstack` tries to provision runs with higher priority first. Defaults to `0`", - "maximum": 100, - "minimum": 0, - "title": "Priority", - "type": "integer" + "title": "Priority" }, "privileged": { "default": false, @@ -3401,50 +4650,76 @@ "type": "boolean" }, "python": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/PythonVersion" + }, { - "$ref": "#/definitions/PythonVersion" + "type": "null" } ], + "default": null, "description": "The major version of Python. Mutually exclusive with `image` and `docker`" }, "regions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)", - "items": { - "type": "string" - }, - "title": "Regions", - "type": "array" + "title": "Regions" }, "registry_auth": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/RegistryAuthRequest" + "$ref": "#/$defs/RegistryAuth" + }, + { + "type": "null" } ], - "description": "Credentials for pulling a private Docker image", - "title": "Registry Auth" + "default": null, + "description": "Credentials for pulling a private Docker image" }, "repos": { "default": [], "description": "The list of Git repos", "items": { - "$ref": "#/definitions/RepoSpecRequest" + "anyOf": [ + { + "$ref": "#/$defs/RepoSpec" + }, + { + "type": "string" + } + ] }, "title": "Repos", "type": "array" }, "reservation": { - "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", - "title": "Reservation", - "type": "string" - }, - "resources": { - "allOf": [ + "anyOf": [ + { + "type": "string" + }, { - "$ref": "#/definitions/ResourcesSpecRequest" + "type": "null" } ], + "default": null, + "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", + "title": "Reservation" + }, + "resources": { + "$ref": "#/$defs/ResourcesSpec", "default": { "cpu": { "max": null, @@ -3473,29 +4748,35 @@ }, "shm_size": null }, - "description": "The resources requirements to run the configuration", - "title": "Resources" + "description": "The resources requirements to run the configuration" }, "retry": { "anyOf": [ { - "$ref": "#/definitions/ProfileRetryRequest" + "$ref": "#/$defs/ProfileRetry" }, { "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for resubmitting the run. Defaults to `false`", "title": "Retry" }, "schedule": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/Schedule" + }, { - "$ref": "#/definitions/ScheduleRequest" + "type": "null" } ], - "description": "The schedule for starting the run at specified time", - "title": "Schedule" + "default": null, + "description": "The schedule for starting the run at specified time" }, "setup": { "default": [], @@ -3506,89 +4787,132 @@ "type": "array" }, "shell": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The shell used to run commands. Allowed values are `sh`, `bash`, or an absolute path, e.g., `/usr/bin/zsh`. Defaults to `/bin/sh` if the `image` is specified, `/bin/bash` otherwise", - "title": "Shell", - "type": "string" + "title": "Shell" }, "single_branch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, "description": "Whether to clone and track only the current branch or all remote branches. Relevant only when using remote Git repos. Defaults to `false` for dev environments and to `true` for tasks and services", - "title": "Single Branch", - "type": "boolean" + "title": "Single Branch" }, "spot_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/SpotPolicy" + }, { - "$ref": "#/definitions/SpotPolicy" + "type": "null" } ], + "default": null, "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`" }, "startup_order": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StartupOrder" + }, { - "$ref": "#/definitions/StartupOrder" + "type": "null" } ], + "default": null, "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`" }, "stop_criteria": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/StopCriteria" + "$ref": "#/$defs/StopCriteria" + }, + { + "type": "null" } ], + "default": null, "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`" }, "stop_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`", "title": "Stop Duration" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "type": { + "const": "task", "default": "task", - "enum": [ - "task" - ], "title": "Type", "type": "string" }, "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The user inside the container, `user_name_or_id[:group_name_or_id]` (e.g., `ubuntu`, `1000:1000`). Defaults to the default user from the `image`", - "title": "User", - "type": "string" + "title": "User" }, "utilization_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/UtilizationPolicy" + }, { - "$ref": "#/definitions/UtilizationPolicyRequest" + "type": "null" } ], - "description": "Run termination policy based on utilization", - "title": "Utilization Policy" + "default": null, + "description": "Run termination policy based on utilization" }, "volumes": { "default": [], @@ -3596,10 +4920,10 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/VolumeMountPointRequest" + "$ref": "#/$defs/VolumeMountPoint" }, { - "$ref": "#/definitions/InstanceMountPointRequest" + "$ref": "#/$defs/InstanceMountPoint" }, { "type": "string" @@ -3610,15 +4934,23 @@ "type": "array" }, "working_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The absolute path to the working directory inside the container. Defaults to the `image`'s default working directory", - "title": "Working Dir", - "type": "string" + "title": "Working Dir" } }, - "title": "TaskConfigurationRequest", + "title": "TaskConfiguration", "type": "object" }, - "UtilizationPolicyRequest": { + "UtilizationPolicy": { "additionalProperties": false, "properties": { "min_gpu_utilization": { @@ -3645,11 +4977,10 @@ "min_gpu_utilization", "time_window" ], - "title": "UtilizationPolicyRequest", + "title": "UtilizationPolicy", "type": "object" }, "VastAIOfferOrder": { - "description": "An enumeration.", "enum": [ "score", "price" @@ -3661,31 +4992,49 @@ "additionalProperties": false, "properties": { "min_reliability": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The minimum reliability threshold for offers, on a scale from `0` to `1`. Defaults to `0.9`", - "maximum": 1, - "minimum": 0, - "title": "Min Reliability", - "type": "number" + "title": "Min Reliability" }, "min_score": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The minimum overall score required for offers to be considered. The scoring scale varies and may require experimentation. Starting with a value in the low hundreds is generally recommended", - "minimum": 0, - "title": "Min Score", - "type": "integer" + "title": "Min Score" }, "offer_order": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/VastAIOfferOrder" + }, { - "$ref": "#/definitions/VastAIOfferOrder" + "type": "null" } ], + "default": null, "description": "Controls the order in which offers are considered for provisioning. Use `score` to prioritize the highest overall score first (the default order in the Vast.ai console), or `price` to prioritize the lowest-cost offers first. Lower-cost offers are often less reliable, so consider applying stricter filters when using `price`. Defaults to `score`" }, "type": { + "const": "vastai", "default": "vastai", - "enum": [ - "vastai" - ], "title": "Type", "type": "string" } @@ -3693,34 +5042,33 @@ "title": "VastAIProfileOptions", "type": "object" }, - "VolumeConfigurationRequest": { - "additionalProperties": false, + "VolumeConfiguration": { "discriminator": { "mapping": { - "aws": "#/definitions/AWSVolumeConfigurationRequest", - "gcp": "#/definitions/GCPVolumeConfigurationRequest", - "kubernetes": "#/definitions/KubernetesVolumeConfigurationRequest", - "runpod": "#/definitions/RunpodVolumeConfigurationRequest" + "aws": "#/$defs/AWSVolumeConfiguration", + "gcp": "#/$defs/GCPVolumeConfiguration", + "kubernetes": "#/$defs/KubernetesVolumeConfiguration", + "runpod": "#/$defs/RunpodVolumeConfiguration" }, "propertyName": "backend" }, "oneOf": [ { - "$ref": "#/definitions/AWSVolumeConfigurationRequest" + "$ref": "#/$defs/AWSVolumeConfiguration" }, { - "$ref": "#/definitions/GCPVolumeConfigurationRequest" + "$ref": "#/$defs/GCPVolumeConfiguration" }, { - "$ref": "#/definitions/RunpodVolumeConfigurationRequest" + "$ref": "#/$defs/RunpodVolumeConfiguration" }, { - "$ref": "#/definitions/KubernetesVolumeConfigurationRequest" + "$ref": "#/$defs/KubernetesVolumeConfiguration" } ], - "title": "VolumeConfigurationRequest" + "title": "VolumeConfiguration" }, - "VolumeMountPointRequest": { + "VolumeMountPoint": { "additionalProperties": false, "properties": { "name": { @@ -3748,40 +5096,42 @@ "name", "path" ], - "title": "VolumeMountPointRequest", + "title": "VolumeMountPoint", "type": "object" } }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, "discriminator": { "mapping": { - "dev-environment": "#/definitions/DevEnvironmentConfigurationRequest", - "fleet": "#/definitions/FleetConfigurationRequest", - "gateway": "#/definitions/GatewayConfigurationRequest", - "service": "#/definitions/ServiceConfigurationRequest", - "task": "#/definitions/TaskConfigurationRequest", - "volume": "#/definitions/VolumeConfigurationRequest" + "dev-environment": "#/$defs/DevEnvironmentConfiguration", + "fleet": "#/$defs/FleetConfiguration", + "gateway": "#/$defs/GatewayConfiguration", + "service": "#/$defs/ServiceConfiguration", + "task": "#/$defs/TaskConfiguration", + "volume": "#/$defs/VolumeConfiguration" }, "propertyName": "type" }, "oneOf": [ { - "$ref": "#/definitions/DevEnvironmentConfigurationRequest" + "$ref": "#/$defs/DevEnvironmentConfiguration" }, { - "$ref": "#/definitions/TaskConfigurationRequest" + "$ref": "#/$defs/TaskConfiguration" }, { - "$ref": "#/definitions/ServiceConfigurationRequest" + "$ref": "#/$defs/ServiceConfiguration" }, { - "$ref": "#/definitions/FleetConfigurationRequest" + "$ref": "#/$defs/FleetConfiguration" }, { - "$ref": "#/definitions/GatewayConfigurationRequest" + "$ref": "#/$defs/GatewayConfiguration" }, { - "$ref": "#/definitions/VolumeConfigurationRequest" + "$ref": "#/$defs/VolumeConfiguration" } ], - "title": "DstackConfigurationRequest" + "title": "DstackConfiguration" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/schema/profiles.json b/src/tests/_internal/pydantic_compat/fixtures/schema/profiles.json index 9a71d9e472..94de590152 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/schema/profiles.json +++ b/src/tests/_internal/pydantic_compat/fixtures/schema/profiles.json @@ -1,7 +1,5 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": false, - "definitions": { + "$defs": { "BackendType": { "description": "Attributes:\n AMDDEVCLOUD (BackendType): AMD Developer Cloud\n AWS (BackendType): Amazon Web Services\n AZURE (BackendType): Microsoft Azure\n CLOUDRIFT (BackendType): CloudRift\n CRUSOE (BackendType): Crusoe\n CUDO (BackendType): Cudo\n DATACRUNCH (BackendType): DataCrunch (for backward compatibility)\n DIGITALOCEAN (BackendType): DigitalOcean\n DSTACK (BackendType): dstack Sky\n GCP (BackendType): Google Cloud Platform\n HOTAISLE (BackendType): Hot Aisle\n JARVISLABS (BackendType): JarvisLabs\n KUBERNETES (BackendType): Kubernetes\n LAMBDA (BackendType): Lambda Cloud\n NEBIUS (BackendType): Nebius AI Cloud\n OCI (BackendType): Oracle Cloud Infrastructure\n RUNPOD (BackendType): Runpod Cloud\n TENSORDOCK (BackendType): TensorDock Marketplace\n VASTAI (BackendType): Vast.ai Marketplace\n VERDA (BackendType): Verda Cloud\n VULTR (BackendType): Vultr\n SLURM (BackendType): Slurm", "enum": [ @@ -33,7 +31,6 @@ "type": "string" }, "CreationPolicy": { - "description": "An enumeration.", "enum": [ "reuse", "reuse-or-create" @@ -41,7 +38,7 @@ "title": "CreationPolicy", "type": "string" }, - "EntityReferenceRequest": { + "EntityReference": { "additionalProperties": false, "description": "Cross-project entity reference.", "properties": { @@ -51,32 +48,38 @@ "type": "string" }, "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The project name. If unspecified, refers to the current project", - "title": "Project", - "type": "string" + "title": "Project" } }, "required": [ "name" ], - "title": "EntityReferenceRequest", + "title": "EntityReference", "type": "object" }, - "FleetInstanceSelectorRequest": { + "FleetInstanceSelector": { "additionalProperties": false, "properties": { "fleet": { "anyOf": [ { - "$ref": "#/definitions/EntityReferenceRequest" + "$ref": "#/$defs/EntityReference" }, { - "minLength": 1, "type": "string" } ], - "description": "The fleet reference. For fleets owned by the current project, specify the fleet name. For a fleet from another project, specify `/` or an object with `project` and `name`.", - "title": "Fleet" + "description": "The fleet reference. For fleets owned by the current project, specify the fleet name. For a fleet from another project, specify `/` or an object with `project` and `name`." }, "instance": { "description": "The fleet instance number", @@ -89,10 +92,10 @@ "fleet", "instance" ], - "title": "FleetInstanceSelectorRequest", + "title": "FleetInstanceSelector", "type": "object" }, - "InstanceHostnameSelectorRequest": { + "InstanceHostnameSelector": { "additionalProperties": false, "properties": { "hostname": { @@ -105,10 +108,10 @@ "required": [ "hostname" ], - "title": "InstanceHostnameSelectorRequest", + "title": "InstanceHostnameSelector", "type": "object" }, - "InstanceNameSelectorRequest": { + "InstanceNameSelector": { "additionalProperties": false, "properties": { "name": { @@ -121,41 +124,70 @@ "required": [ "name" ], - "title": "InstanceNameSelectorRequest", + "title": "InstanceNameSelector", "type": "object" }, - "ProfileRequest": { + "Profile": { "additionalProperties": false, "properties": { "availability_zones": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The availability zones to consider for provisioning (e.g., `[eu-west-1a, us-west4-a]`)", - "items": { - "type": "string" - }, - "title": "Availability Zones", - "type": "array" + "title": "Availability Zones" }, "backend_options": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/VastAIProfileOptions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "Backend-specific options, applied only to offers from that backend", - "items": { - "$ref": "#/definitions/VastAIProfileOptions" - }, - "title": "Backend Options", - "type": "array" + "title": "Backend Options" }, "backends": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/BackendType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The backends to consider for provisioning (e.g., `[aws, gcp]`)", - "items": { - "$ref": "#/definitions/BackendType" - }, - "type": "array" + "title": "Backends" }, "creation_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/CreationPolicy" + "$ref": "#/$defs/CreationPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for using instances from fleets: `reuse`, `reuse-or-create`. Defaults to `reuse-or-create`" }, "default": { @@ -165,89 +197,129 @@ "type": "boolean" }, "fleets": { - "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/EntityReferenceRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntityReference" + }, + { + "type": "string" + } + ] }, - { - "type": "string" - } - ] - }, - "title": "Fleets", - "type": "array" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The fleets considered for reuse. For fleets owned by the current project, specify fleet names. For imported fleets, specify `/`", + "title": "Fleets" }, "idle_duration": { "anyOf": [ + { + "const": "off", + "type": "string" + }, { "type": "integer" }, { "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "Time to wait before terminating idle instances. When the run reuses an existing fleet instance, the fleet's `idle_duration` applies. When the run provisions a new instance, the shorter of the fleet's and run's values is used. Defaults to `5m` for runs and `3d` for fleets. Use `off` for unlimited duration. Only applied for VM-based backends", "title": "Idle Duration" }, "instance_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The cloud-specific instance types to consider for provisioning (e.g., `[g6e.24xlarge, n1-standard-4]`)", - "items": { - "type": "string" - }, - "title": "Instance Types", - "type": "array" + "title": "Instance Types" }, "instances": { - "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/InstanceNameSelectorRequest" - }, - { - "$ref": "#/definitions/InstanceHostnameSelectorRequest" - }, - { - "$ref": "#/definitions/FleetInstanceSelectorRequest" + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/InstanceNameSelector" + }, + { + "$ref": "#/$defs/InstanceHostnameSelector" + }, + { + "$ref": "#/$defs/FleetInstanceSelector" + }, + { + "type": "string" + } + ] }, - { - "minLength": 1, - "type": "string" - } - ] - }, - "minItems": 1, - "title": "Instances", - "type": "array" + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The specific fleet instances to consider for reuse. Each value can be an instance name string, or an object with `name`, `hostname`, or `fleet` and `instance`. When set, the run is only placed on matching existing instances.", + "title": "Instances" }, "max_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run (e.g., `2h`, `1d`, etc) in a running state, excluding provisioning and pulling. After it elapses, the run is automatically stopped. Use `off` for unlimited duration. Defaults to `off`", "title": "Max Duration" }, "max_price": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The maximum instance price per hour, in dollars", - "exclusiveMinimum": 0.0, - "title": "Max Price", - "type": "number" + "title": "Max Price" }, "name": { "default": "", @@ -256,106 +328,150 @@ "type": "string" }, "regions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The regions to consider for provisioning (e.g., `[eu-west-1, us-west4, westeurope]`)", - "items": { - "type": "string" - }, - "title": "Regions", - "type": "array" + "title": "Regions" }, "reservation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, "description": "The existing reservation to use for instance provisioning. Supports AWS Capacity Reservations, AWS Capacity Blocks, and GCP reservations", - "title": "Reservation", - "type": "string" + "title": "Reservation" }, "retry": { "anyOf": [ { - "$ref": "#/definitions/ProfileRetryRequest" + "$ref": "#/$defs/ProfileRetry" }, { "type": "boolean" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for resubmitting the run. Defaults to `false`", "title": "Retry" }, "schedule": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/ScheduleRequest" + "$ref": "#/$defs/Schedule" + }, + { + "type": "null" } ], - "description": "The schedule for starting the run at specified time", - "title": "Schedule" + "default": null, + "description": "The schedule for starting the run at specified time" }, "spot_policy": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/SpotPolicy" + "$ref": "#/$defs/SpotPolicy" + }, + { + "type": "null" } ], + "default": null, "description": "The policy for provisioning spot or on-demand instances: `spot`, `on-demand`, `auto`. Defaults to `on-demand`" }, "startup_order": { - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/StartupOrder" + "$ref": "#/$defs/StartupOrder" + }, + { + "type": "null" } ], + "default": null, "description": "The order in which master and workers jobs are started: `any`, `master-first`, `workers-first`. Defaults to `any`" }, "stop_criteria": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/StopCriteria" + }, { - "$ref": "#/definitions/StopCriteria" + "type": "null" } ], + "default": null, "description": "The criteria determining when a multi-node run should be considered finished: `all-done`, `master-done`. Defaults to `all-done`" }, "stop_duration": { "anyOf": [ { - "enum": [ - "off" - ], - "type": "string" + "type": "integer" }, { - "type": "integer" + "type": "string" }, { "type": "boolean" }, { - "type": "string" + "type": "null" } ], + "default": null, "description": "The maximum duration of a run graceful stopping. After it elapses, the run is automatically forced stopped. This includes force detaching volumes used by the run. Use `off` for unlimited duration. Defaults to `5m`", "title": "Stop Duration" }, "tags": { - "additionalProperties": { - "type": "string" - }, + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, "description": "The custom tags to associate with the resource. The tags are also propagated to the underlying backend resources. If there is a conflict with backend-level tags, does not override them", - "title": "Tags", - "type": "object" + "title": "Tags" }, "utilization_policy": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/UtilizationPolicy" + }, { - "$ref": "#/definitions/UtilizationPolicyRequest" + "type": "null" } ], - "description": "Run termination policy based on utilization", - "title": "Utilization Policy" + "default": null, + "description": "Run termination policy based on utilization" } }, - "title": "ProfileRequest", + "title": "Profile", "type": "object" }, - "ProfileRetryRequest": { + "ProfileRetry": { "additionalProperties": false, "properties": { "duration": { @@ -365,24 +481,36 @@ }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "description": "The maximum period of retrying the run, e.g., `4h` or `1d`. The period is calculated as a run age for `no-capacity` event and as a time passed since the last `interruption` and `error` for `interruption` and `error` events.", "title": "Duration" }, "on_events": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RetryEvent" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, "description": "The list of events that should be handled with retry. Supported events are `no-capacity`, `interruption`, `error`. Omit to retry on all events", - "items": { - "$ref": "#/definitions/RetryEvent" - }, - "type": "array" + "title": "On Events" } }, - "title": "ProfileRetryRequest", + "title": "ProfileRetry", "type": "object" }, "RetryEvent": { - "description": "An enumeration.", "enum": [ "no-capacity", "interruption", @@ -391,7 +519,7 @@ "title": "RetryEvent", "type": "string" }, - "ScheduleRequest": { + "Schedule": { "additionalProperties": false, "properties": { "cron": { @@ -413,11 +541,10 @@ "required": [ "cron" ], - "title": "ScheduleRequest", + "title": "Schedule", "type": "object" }, "SpotPolicy": { - "description": "An enumeration.", "enum": [ "spot", "on-demand", @@ -427,7 +554,6 @@ "type": "string" }, "StartupOrder": { - "description": "An enumeration.", "enum": [ "any", "master-first", @@ -437,7 +563,6 @@ "type": "string" }, "StopCriteria": { - "description": "An enumeration.", "enum": [ "all-done", "master-done" @@ -445,7 +570,7 @@ "title": "StopCriteria", "type": "string" }, - "UtilizationPolicyRequest": { + "UtilizationPolicy": { "additionalProperties": false, "properties": { "min_gpu_utilization": { @@ -472,11 +597,10 @@ "min_gpu_utilization", "time_window" ], - "title": "UtilizationPolicyRequest", + "title": "UtilizationPolicy", "type": "object" }, "VastAIOfferOrder": { - "description": "An enumeration.", "enum": [ "score", "price" @@ -488,31 +612,49 @@ "additionalProperties": false, "properties": { "min_reliability": { + "anyOf": [ + { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, "description": "The minimum reliability threshold for offers, on a scale from `0` to `1`. Defaults to `0.9`", - "maximum": 1, - "minimum": 0, - "title": "Min Reliability", - "type": "number" + "title": "Min Reliability" }, "min_score": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, "description": "The minimum overall score required for offers to be considered. The scoring scale varies and may require experimentation. Starting with a value in the low hundreds is generally recommended", - "minimum": 0, - "title": "Min Score", - "type": "integer" + "title": "Min Score" }, "offer_order": { - "allOf": [ + "anyOf": [ + { + "$ref": "#/$defs/VastAIOfferOrder" + }, { - "$ref": "#/definitions/VastAIOfferOrder" + "type": "null" } ], + "default": null, "description": "Controls the order in which offers are considered for provisioning. Use `score` to prioritize the highest overall score first (the default order in the Vast.ai console), or `price` to prioritize the lowest-cost offers first. Lower-cost offers are often less reliable, so consider applying stricter filters when using `price`. Defaults to `score`" }, "type": { + "const": "vastai", "default": "vastai", - "enum": [ - "vastai" - ], "title": "Type", "type": "string" } @@ -521,10 +663,12 @@ "type": "object" } }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, "properties": { "profiles": { "items": { - "$ref": "#/definitions/ProfileRequest" + "$ref": "#/$defs/Profile" }, "title": "Profiles", "type": "array" @@ -533,6 +677,6 @@ "required": [ "profiles" ], - "title": "ProfilesConfigRequest", + "title": "ProfilesConfig", "type": "object" } diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/fleet.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/fleet.json index 2b0b2d6f78..ead4a09501 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/fleet.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/fleet.json @@ -1,15 +1,16 @@ { - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "id": "11111111-1111-4111-8111-111111111111", "instances": [ { "availability_zone": null, "backend": "aws", "busy_blocks": 0, - "created": "2024-01-02T03:04:05+00:00", + "created": "2024-01-02T03:04:05Z", "finished_at": null, "fleet_id": null, "fleet_name": null, + "gpu_driver": null, "health_status": "healthy", "hostname": null, "id": "11111111-1111-4111-8111-111111111111", diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/gateway.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/gateway.json index 0a6bee732b..3cacb9291b 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/gateway.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/gateway.json @@ -16,7 +16,7 @@ "tags": null, "type": "gateway" }, - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "default": true, "hostname": "gateway.example.com", "id": "11111111-1111-4111-8111-111111111111", diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/project.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/project.json index b4a6f0a4c3..3e0c4e4b73 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/project.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/project.json @@ -11,7 +11,7 @@ "project_role": "admin", "user": { "active": true, - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "email": null, "global_role": "user", "id": "11111111-1111-4111-8111-111111111111", @@ -25,7 +25,7 @@ ], "owner": { "active": true, - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "email": null, "global_role": "user", "id": "11111111-1111-4111-8111-111111111111", diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/user_with_creds.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/user_with_creds.json index 97c4e2f379..f2d67619e0 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/user_with_creds.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/user_with_creds.json @@ -1,6 +1,6 @@ { "active": true, - "created_at": "2024-01-02T03:04:05+00:00", + "created_at": "2024-01-02T03:04:05Z", "creds": { "token": "test-token" }, diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/volume.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/volume.json index 80ec497bad..c4b59fd8f8 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/volume.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/api_response/volume.json @@ -12,13 +12,13 @@ "type": "volume", "volume_id": null }, - "cost": 0, - "created_at": "2024-01-02T03:04:05+00:00", + "cost": 0.0, + "created_at": "2024-01-02T03:04:05Z", "deleted": false, "deleted_at": null, "external": false, "id": "11111111-1111-4111-8111-111111111111", - "last_processed_at": "2024-01-02T03:04:05+00:00", + "last_processed_at": "2024-01-02T03:04:05Z", "name": "test-volume", "project_name": "test-project", "provisioning_data": null, diff --git a/src/tests/_internal/pydantic_compat/fixtures/serialization/db/job_provisioning_data.json b/src/tests/_internal/pydantic_compat/fixtures/serialization/db/job_provisioning_data.json index 6f8c681be2..81f60576d1 100644 --- a/src/tests/_internal/pydantic_compat/fixtures/serialization/db/job_provisioning_data.json +++ b/src/tests/_internal/pydantic_compat/fixtures/serialization/db/job_provisioning_data.json @@ -4,6 +4,7 @@ "backend_data": null, "base_backend": null, "dockerized": true, + "gpu_driver": null, "hostname": "127.0.0.4", "instance_id": "instance_id", "instance_network": null, diff --git a/src/tests/_internal/pydantic_compat/test_custom_types.py b/src/tests/_internal/pydantic_compat/test_custom_types.py index cdcade6556..97f6a3ec79 100644 --- a/src/tests/_internal/pydantic_compat/test_custom_types.py +++ b/src/tests/_internal/pydantic_compat/test_custom_types.py @@ -11,9 +11,9 @@ from typing import Any, Callable import pytest -from pydantic import ValidationError, parse_obj_as +from pydantic import TypeAdapter, ValidationError -from dstack._internal.core.models.common import Duration +from dstack._internal.core.models.duration import Duration from dstack._internal.core.models.gateways import GatewaySpec from dstack._internal.core.models.resources import ( ComputeCapability, @@ -92,19 +92,19 @@ def test_specialization_preserves_spec_type_and_production_request_body( ): """ Pydantic v1 represents these specializations as typing aliases and adds - `__orig_class__` after construction. Production sends `.dict()` to requests, relying on + `__orig_class__` after construction. Production sends `.model_dump()` to requests, relying on the request model's override to remove that non-JSON value. """ spec = spec_factory() request = request_model(user="alice", project="main", spec=spec) assert class_name(request.spec) == expected_spec_type - body = request.dict() + body = request.model_dump() assert "__orig_class__" not in body # Match the exact production handoff: requests receives a plain, stdlib-JSON-safe dict. encoded_body = json.dumps(body) - assert canonicalize(json.dumps(body["spec"])) == canonicalize(spec.json()) + assert canonicalize(json.dumps(body["spec"])) == canonicalize(spec.model_dump_json()) # The generic must also choose the same type when its value arrives as an untyped payload. reparsed_request = request_model(**json.loads(encoded_body)) @@ -160,24 +160,24 @@ def test_specialization_preserves_bound_types_and_json( expected: dict[str, Any], bound_type: type, ): - value = parse_obj_as(range_type, raw) + value = TypeAdapter(range_type).validate_python(raw) assert type(value) is range_type - assert value.dict() == expected + assert value.model_dump() == expected for bound in (value.min, value.max): if bound is not None: assert type(bound) is bound_type - assert canonicalize(value.json()) == canonicalize(json.dumps(expected)) + assert canonicalize(value.model_dump_json()) == canonicalize(json.dumps(expected)) @pytest.mark.parametrize("raw", ["..", "8..2", "1...3"]) def test_invalid_int_ranges_stay_rejected(self, raw: str): with pytest.raises(ValidationError): - parse_obj_as(Range[int], raw) + Range[int].model_validate(raw) @pytest.mark.parametrize("raw", ["...", "2TB..1TB"]) def test_invalid_memory_ranges_stay_rejected(self, raw: str): with pytest.raises(ValidationError): - parse_obj_as(Range[Memory], raw) + Range[Memory].model_validate(raw) _SCALAR_CASES = [ @@ -239,7 +239,7 @@ def test_parsed_value_type_and_json_stay_stable( expected_type: type, expected_json: Any, ): - value = parse_obj_as(scalar_type, raw) + value = TypeAdapter(scalar_type).validate_python(raw) assert value == expected assert type(value) is expected_type @@ -258,7 +258,7 @@ def test_parsed_value_type_and_json_stay_stable( ) def test_invalid_values_stay_rejected(self, scalar_type: Any, raw: Any): with pytest.raises(ValidationError): - parse_obj_as(scalar_type, raw) + TypeAdapter(scalar_type).validate_python(raw) _CUSTOM_MODEL_CASES = [ @@ -391,9 +391,9 @@ def test_custom_parser_preserves_values_types_and_json( expected_json: dict[str, Any], expected_types: dict[str, str], ): - value = parse_obj_as(model_type, raw) + value = TypeAdapter(model_type).validate_python(raw) - assert canonicalize(value.json()) == canonicalize(json.dumps(expected_json)) + assert canonicalize(value.model_dump_json()) == canonicalize(json.dumps(expected_json)) assert type_map(value) == expected_types @pytest.mark.parametrize( @@ -408,4 +408,4 @@ def test_custom_parser_preserves_values_types_and_json( ) def test_invalid_custom_model_values_stay_rejected(self, model_type: Any, raw: Any): with pytest.raises(ValidationError): - parse_obj_as(model_type, raw) + TypeAdapter(model_type).validate_python(raw) diff --git a/src/tests/_internal/pydantic_compat/test_field_parsing.py b/src/tests/_internal/pydantic_compat/test_field_parsing.py index dabf94e81a..6fe9aaa46f 100644 --- a/src/tests/_internal/pydantic_compat/test_field_parsing.py +++ b/src/tests/_internal/pydantic_compat/test_field_parsing.py @@ -7,7 +7,6 @@ import yaml from dstack._internal.core.errors import ConfigurationError -from dstack._internal.core.models.common import Duration from dstack._internal.core.models.configurations import ( PythonVersion, parse_apply_configuration, @@ -39,7 +38,7 @@ def test_yaml_310_float_is_recovered_as_python_310(self): config = parse_apply_configuration(data) assert config.python is PythonVersion.PY310 - assert json.loads(config.json())["python"] == "3.10" + assert json.loads(config.model_dump_json())["python"] == "3.10" def test_yaml_311_float_stays_python_311(self): data = yaml.safe_load( @@ -63,7 +62,7 @@ def test_list_is_normalized_to_mapping_and_missing_value_becomes_sentinel(self): assert config.env["EMPTY"] == "" assert config.env["B"] == EnvSentinel(key="B") assert class_name(config.env["B"]) == "EnvSentinel" - assert json.loads(config.json())["env"] == { + assert json.loads(config.model_dump_json())["env"] == { "A": "1", "B": {"key": "B"}, "EMPTY": "", @@ -86,7 +85,7 @@ def test_task_port_variants_are_normalized_to_port_mappings(self): config = parse_apply_configuration(_task(ports=[8080, "8081:81", "*:82"])) assert all(class_name(port) == "PortMapping" for port in config.ports) - assert [port.dict() for port in config.ports] == [ + assert [port.model_dump() for port in config.ports] == [ {"local_port": 8080, "container_port": 8080}, {"local_port": 8081, "container_port": 81}, {"local_port": None, "container_port": 82}, @@ -111,7 +110,7 @@ def test_service_port_variants_are_normalized(self, raw: Any, expected: dict[str config = parse_apply_configuration(_service(port=raw)) assert class_name(config.port) == "PortMapping" - assert config.port.dict() == expected + assert config.port.model_dump() == expected class TestMountPointFieldParsing: @@ -121,9 +120,9 @@ def test_string_arms_select_volume_and_instance_mount_models(self): ) assert class_name(config.volumes[0]) == "VolumeMountPoint" - assert config.volumes[0].dict() == {"name": "my-volume", "path": "/mnt/data"} + assert config.volumes[0].model_dump() == {"name": "my-volume", "path": "/mnt/data"} assert class_name(config.volumes[1]) == "InstanceMountPoint" - assert config.volumes[1].dict() == { + assert config.volumes[1].model_dump() == { "instance_path": "/host/cache", "path": "/cache", "optional": False, @@ -142,7 +141,7 @@ def test_unix_and_windows_sources_keep_colons_in_the_source_path(self): ) assert all(class_name(mapping) == "FilePathMapping" for mapping in config.files) - assert [mapping.dict() for mapping in config.files] == [ + assert [mapping.model_dump() for mapping in config.files] == [ {"local_path": "data", "path": "/workspace/data"}, {"local_path": r"C:\data", "path": "/workspace/windows"}, ] @@ -230,7 +229,7 @@ def test_user_field_runs_unix_user_validation_without_changing_wire_value( config = parse_apply_configuration(_task(user=raw)) assert config.user == raw - assert UnixUser.parse(config.user).dict() == parsed + assert UnixUser.parse(config.user).model_dump() == parsed @pytest.mark.parametrize( "raw", @@ -253,7 +252,10 @@ def test_invalid_user_stays_rejected(self, raw: str): pytest.param("idle_duration", "off", -1, int, id="idle-off"), pytest.param("idle_duration", False, -1, int, id="idle-false"), pytest.param("idle_duration", -1, -1, int, id="idle-legacy-minus-one"), - pytest.param("max_duration", "2h", 7200, Duration, id="max-duration"), + # `int`, not `Duration`: the field is declared `Union[Literal["off"], int]`, so the `Duration` + # int-subclass the before-validator returns does not survive validation. Value and wire format + # are unaffected, and nothing does `isinstance(..., Duration)`. + pytest.param("max_duration", "2h", 7200, int, id="max-duration"), ] @@ -291,7 +293,7 @@ def test_string_shorthand_becomes_openai_model(self): config = parse_apply_configuration(_service(model="llama")) assert class_name(config.model) == "OpenAIChatModel" - assert config.model.dict() == { + assert config.model.model_dump() == { "type": "chat", "name": "llama", "format": "openai", @@ -320,7 +322,36 @@ def test_project_qualified_string_becomes_entity_reference(self): config = parse_apply_configuration(_service(gateway="other-project/shared-gateway")) assert class_name(config.gateway) == "EntityReference" - assert config.gateway.dict() == { + assert config.gateway.model_dump() == { "project": "other-project", "name": "shared-gateway", } + + def test_bare_string_becomes_entity_reference(self): + config = parse_apply_configuration(_service(gateway="shared-gateway")) + + assert class_name(config.gateway) == "EntityReference" + assert config.gateway.model_dump() == {"project": None, "name": "shared-gateway"} + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (True, True), + (False, False), + # Quoted in YAML, so the field receives a `str`. The `bool` arm must still claim it: + # the union also has a `str` arm, which pydantic v2's "smart" mode prefers for an exact + # type match, turning `gateway: "false"` into a gateway *named* `false`. + ("true", True), + ("false", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ("1", True), + ("0", False), + ], + ) + def test_boolean_and_quoted_boolean_stay_boolean(self, value: Any, expected: bool): + config = parse_apply_configuration(_service(gateway=value)) + + assert config.gateway is expected diff --git a/src/tests/_internal/pydantic_compat/test_parsing.py b/src/tests/_internal/pydantic_compat/test_parsing.py index f3c7c59093..087e3e1cf1 100644 --- a/src/tests/_internal/pydantic_compat/test_parsing.py +++ b/src/tests/_internal/pydantic_compat/test_parsing.py @@ -78,7 +78,7 @@ def _derived_registry(surface: str) -> dict[str, Any]: # The `config` column is written as `XStoredConfig` but read back as `XConfig`, so this is the one # surface whose parse target is a different class from the one that produced the bytes. The read is -# a splice of two columns — `XConfig(**json.loads(config), creds=XCreds.parse_raw(auth))` — and the +# a splice of two columns — `XConfig(**json.loads(config), creds=XCreds.model_validate_json(auth))` — and the # inputs mirror that by carrying `creds` inline, which additionally makes the creds union resolve # here rather than arrive pre-resolved. BACKEND_CONFIGS = backend_factories.BACKEND_CONFIG_MODELS @@ -93,7 +93,7 @@ def _derived_registry(surface: str) -> dict[str, Any]: "dev_environment": parse_apply_configuration, "fleet": parse_apply_configuration, "gateway": parse_apply_configuration, - "profiles": ProfilesConfig.parse_obj, + "profiles": ProfilesConfig.model_validate, "service": parse_apply_configuration, "task": parse_apply_configuration, "volume": parse_apply_configuration, @@ -167,7 +167,7 @@ def test_resolves_the_intended_union_arm(self, name): expected = class_name(backend_factories.CREDS_ARMS[name]) # Compared by name rather than `isinstance`: the permissive read yields duality's # `...Response` variant on v1 and the plain class on v2, and `class_name` erases that. - assert class_name(creds.__root__) == expected + assert class_name(creds.root) == expected class TestBackendDataParsing: @@ -179,7 +179,7 @@ def test_parses_to_expected_values_and_types(self, name, regen): class TestNebiusOfferBackendDataSetField: """ - Excluded from `BACKEND_DATA` because `.json()` raises on its `set` field, so there is nothing + Excluded from `BACKEND_DATA` because `.model_dump_json()` raises on its `set` field, so there is nothing for `_assert_parses` to compare. The read path is real regardless, so assert on the value. """ @@ -234,7 +234,9 @@ def test_unknown_field_is_dropped(self, surface, name): payload = _load_input(surface, name) baseline = parse_ignore_extra(model, payload) perturbed = parse_ignore_extra(model, {**payload, "unknown_from_a_newer_writer": {"x": 1}}) - assert canonicalize(perturbed.json()) == canonicalize(baseline.json()) + assert canonicalize(perturbed.model_dump_json()) == canonicalize( + baseline.model_dump_json() + ) class TestUnknownFieldRejection: @@ -311,7 +313,7 @@ def test_parses_to_expected_values_and_types(self, name, regen): def _assert_parses(surface: str, name: str, model, regen: bool) -> None: kind = f"parsing/{surface}" - assert_matches_fixture(kind, f"{name}.values", model.json(), regen=regen) + assert_matches_fixture(kind, f"{name}.values", model.model_dump_json(), regen=regen) assert_matches_fixture(kind, f"{name}.types", json.dumps(type_map(model)), regen=regen) diff --git a/src/tests/_internal/pydantic_compat/test_rejection.py b/src/tests/_internal/pydantic_compat/test_rejection.py index 2099615dd5..0eab7f5e1a 100644 --- a/src/tests/_internal/pydantic_compat/test_rejection.py +++ b/src/tests/_internal/pydantic_compat/test_rejection.py @@ -39,7 +39,7 @@ def _task(extra_yaml: str) -> Any: ConfigurationError, ), "request_unknown_key": ( - CreateVolumeRequest.parse_obj, + CreateVolumeRequest.model_validate, { "configuration": {"type": "volume", "name": "v", "backend": "aws", "region": "r"}, "unexpected": 1, diff --git a/src/tests/_internal/pydantic_compat/test_schema.py b/src/tests/_internal/pydantic_compat/test_schema.py index 4853558377..26aacf525e 100644 --- a/src/tests/_internal/pydantic_compat/test_schema.py +++ b/src/tests/_internal/pydantic_compat/test_schema.py @@ -38,7 +38,7 @@ class TestPublishedSchemas: @pytest.mark.parametrize("name", sorted(PUBLISHED_SCHEMAS)) def test_matches_fixture(self, name, regen): # The exact call the CI job makes. - schema_json = PUBLISHED_SCHEMAS[name].schema_json() + schema_json = json.dumps(PUBLISHED_SCHEMAS[name].model_json_schema()) assert_matches_fixture("schema", name, schema_json, regen=regen) @pytest.mark.parametrize("name", sorted(PUBLISHED_SCHEMAS)) @@ -51,7 +51,7 @@ def test_every_ref_resolves(self, name): This is not hypothetical: `add_extra_schema_types` in `utils/json_schema.py` rewrites properties in place and has already produced a `KeyError: '$ref'` in this job once. """ - schema = json.loads(PUBLISHED_SCHEMAS[name].schema_json()) + schema = PUBLISHED_SCHEMAS[name].model_json_schema() definitions = _definitions(schema) assert definitions, "expected the schema to define types to reference" unresolved = sorted( diff --git a/src/tests/_internal/pydantic_compat/test_serialization.py b/src/tests/_internal/pydantic_compat/test_serialization.py index 2669ad1c51..efc51f4434 100644 --- a/src/tests/_internal/pydantic_compat/test_serialization.py +++ b/src/tests/_internal/pydantic_compat/test_serialization.py @@ -5,8 +5,8 @@ each registry below — and compared against a fixture generated under pydantic v1. On v1 these are regression tests; on the v2 branch they are the compat assertion. -Nothing here may reference the duality API (`__request__` / `__response__`): these tests have to -run unchanged on both versions, and duality is gone in v2. +Nothing here may reference the duality request/response variants directly: these tests have to run +unchanged on both versions, and duality is gone in v2. See `compat.py`. Disposable: this package is deleted once the v2 release is verified in prod, except for a curated subset of the `db/` fixtures, which outlive it because stored rows do. @@ -28,7 +28,7 @@ from dstack._internal.core.models.repos.remote import RemoteRunRepoData from dstack._internal.core.models.resources import CPUSpec, Range, ResourcesSpec from dstack._internal.core.models.volumes import RunpodVolumeConfiguration, VolumeSpec -from dstack._internal.server.utils.routers import CustomORJSONResponse +from dstack._internal.server.utils.routers import CustomJSONResponse from tests._internal.pydantic_compat import backend_factories, factories from tests._internal.pydantic_compat.compare import assert_matches_fixture, canonicalize @@ -75,7 +75,7 @@ "save_repo_creds_request": factories.save_repo_creds_request, } -# Returned from a router via `CustomORJSONResponse` — orjson with a `default=` hook rather than +# Returned from a router via `CustomJSONResponse` — orjson with a `default=` hook rather than # `.json()`, so this path can drift away from the one above. API_RESPONSES: dict[str, Callable[[], CoreModel]] = { "fleet": factories.fleet, @@ -89,7 +89,8 @@ "volume": factories.volume, } -# Sent by the server to the runner (shim) as a request body, via `.json()`. +# Sent by the server to the runner (shim) as a request body. `SubmitBody` restricts what it +# sends via `json_for_runner()`; the rest go out as plain `.json()`. RUNNER_REQUESTS: dict[str, Callable[[], CoreModel]] = { "component_install_request": factories.component_install_request, "legacy_submit_body": factories.legacy_submit_body, @@ -127,16 +128,21 @@ # unknown-field tolerance: it has to use the identical path, or it compares orjson output against # `.json()` output and fails for reasons that have nothing to do with parsing. SURFACES: dict[str, tuple[dict[str, Callable[[], Any]], Callable[[Any], Union[bytes, str]]]] = { - "db": (DB_BLOBS, lambda model: model.json()), - "backend_config": (BACKEND_STORED_CONFIGS, lambda model: model.json()), - "backend_creds": (BACKEND_CREDS, lambda model: model.json()), - "backend_data": (BACKEND_DATA, lambda model: model.json()), - "api_request": (API_REQUESTS, lambda model: model.json()), - "api_response": (API_RESPONSES, lambda model: bytes(CustomORJSONResponse(model).body)), - "runner": (RUNNER_REQUESTS, lambda model: model.json()), - "gateway": (GATEWAY_RESPONSES, lambda model: model.json()), - "proxy": (PROXY_REQUESTS, lambda model: json.dumps(model.dict(exclude_unset=True))), - "proxy_response": (PROXY_RESPONSES, lambda model: model.json()), + "db": (DB_BLOBS, lambda model: model.model_dump_json()), + "backend_config": (BACKEND_STORED_CONFIGS, lambda model: model.model_dump_json()), + "backend_creds": (BACKEND_CREDS, lambda model: model.model_dump_json()), + "backend_data": (BACKEND_DATA, lambda model: model.model_dump_json()), + "api_request": (API_REQUESTS, lambda model: model.model_dump_json()), + "api_response": (API_RESPONSES, lambda model: bytes(CustomJSONResponse(model).body)), + "runner": ( + RUNNER_REQUESTS, + lambda model: model.json_for_runner() + if hasattr(model, "json_for_runner") + else model.model_dump_json(), + ), + "gateway": (GATEWAY_RESPONSES, lambda model: model.model_dump_json()), + "proxy": (PROXY_REQUESTS, lambda model: json.dumps(model.model_dump(exclude_unset=True))), + "proxy_response": (PROXY_RESPONSES, lambda model: model.model_dump_json()), } _CASES = [(surface, name) for surface, (reg, _) in SURFACES.items() for name in sorted(reg)] @@ -160,7 +166,7 @@ class TestFleetNodesTargetCompatHack: """ Pins the #3066 old-client hack explicitly, not just via the fixture bytes. - `FleetNodesSpec.dict()` drops `target` when it equals `min`. A fixture would catch the change + `FleetNodesSpec.model_dump()` drops `target` when it equals `min`. A fixture would catch the change but not explain it; naming the invariant gives the v2 `@model_serializer` rewrite something unambiguous to satisfy. @@ -170,10 +176,10 @@ class TestFleetNodesTargetCompatHack: """ def test_target_is_omitted_when_it_equals_min(self): - assert "target" not in FleetNodesSpec(min=1, target=1, max=1).dict() + assert "target" not in FleetNodesSpec(min=1, target=1, max=1).model_dump() def test_target_is_kept_when_it_differs_from_min(self): - assert FleetNodesSpec(min=1, target=5, max=5).dict()["target"] == 5 + assert FleetNodesSpec(min=1, target=5, max=5).model_dump()["target"] == 5 @pytest.mark.parametrize( ("nodes", "expected"), @@ -191,8 +197,8 @@ def test_target_is_kept_when_it_differs_from_min(self): ], ) def test_dict_and_json_apply_the_same_override(self, nodes, expected): - assert nodes.dict() == expected - assert json.loads(nodes.json()) == expected + assert nodes.model_dump() == expected + assert json.loads(nodes.model_dump_json()) == expected @pytest.mark.parametrize( ("nodes", "expected"), @@ -212,8 +218,8 @@ def test_dict_and_json_apply_the_same_override(self, nodes, expected): def test_override_is_applied_when_nested(self, nodes, expected): configuration = FleetConfiguration(nodes=nodes) - assert configuration.dict()["nodes"] == expected - assert json.loads(configuration.json())["nodes"] == expected + assert configuration.model_dump()["nodes"] == expected + assert json.loads(configuration.model_dump_json())["nodes"] == expected def test_the_api_fixture_exercises_the_hack(self): nodes = factories.fleet().spec.configuration.nodes @@ -243,10 +249,10 @@ def test_dict_and_json_apply_the_same_override(self, arch, expected): cpu=CPUSpec(arch=arch, count=Range[int](min=2, max=8)), ) - assert json.loads(resources.json())["cpu"] == expected + assert json.loads(resources.model_dump_json())["cpu"] == expected # CoreModel.json() must call the overridden dict(); this assertion would expose a drift # even if only one of the two methods retained the compatibility rewrite. - assert canonicalize(json.dumps(resources.dict()["cpu"])) == canonicalize( + assert canonicalize(json.dumps(resources.model_dump()["cpu"])) == canonicalize( json.dumps(expected) ) @@ -269,15 +275,15 @@ def test_override_is_applied_when_nested(self, arch, expected): ), ) - assert json.loads(configuration.json())["resources"]["cpu"] == expected - assert canonicalize(json.dumps(configuration.dict()["resources"]["cpu"])) == canonicalize( - json.dumps(expected) - ) + assert json.loads(configuration.model_dump_json())["resources"]["cpu"] == expected + assert canonicalize( + json.dumps(configuration.model_dump()["resources"]["cpu"]) + ) == canonicalize(json.dumps(expected)) class TestFieldSerializationFilters: def test_submit_body_nested_field_includes_are_preserved(self): - body = factories.submit_body().dict() + body = json.loads(factories.submit_body().json_for_runner()) assert set(body["run"]) == {"id", "run_spec"} assert set(body["run"]["run_spec"]) == { @@ -309,15 +315,15 @@ def test_derived_merged_profile_is_excluded_from_dict_and_json(self): spec = factories.run_spec() assert spec.merged_profile is not None - assert "merged_profile" not in spec.dict() - assert "merged_profile" not in json.loads(spec.json()) + assert "merged_profile" not in spec.model_dump() + assert "merged_profile" not in json.loads(spec.model_dump_json()) def test_remote_repo_diff_bytes_are_excluded_from_dict_and_json(self): repo = RemoteRunRepoData(repo_name="dstack", repo_diff=b"secret diff") assert repo.repo_diff == b"secret diff" - assert "repo_diff" not in repo.dict() - assert "repo_diff" not in json.loads(repo.json()) + assert "repo_diff" not in repo.model_dump() + assert "repo_diff" not in json.loads(repo.model_dump_json()) def test_runpod_compatibility_availability_zone_is_excluded_directly_and_nested(self): configuration = RunpodVolumeConfiguration( @@ -328,19 +334,19 @@ def test_runpod_compatibility_availability_zone_is_excluded_directly_and_nested( ) assert configuration.availability_zone == "legacy-zone" - assert "availability_zone" not in configuration.dict() - assert "availability_zone" not in json.loads(configuration.json()) + assert "availability_zone" not in configuration.model_dump() + assert "availability_zone" not in json.loads(configuration.model_dump_json()) spec = VolumeSpec(configuration=configuration) - assert "availability_zone" not in spec.dict()["configuration"] - assert "availability_zone" not in json.loads(spec.json())["configuration"] + assert "availability_zone" not in spec.model_dump()["configuration"] + assert "availability_zone" not in json.loads(spec.model_dump_json())["configuration"] -class TestCustomORJSONResponseCompat: +class TestCustomJSONResponseCompat: def test_response_uses_the_same_nested_serializer_overrides_as_model_json(self): configuration = FleetConfiguration(nodes=FleetNodesSpec(min=1, target=1, max=1)) - response_body = bytes(CustomORJSONResponse(configuration).body) + response_body = bytes(CustomJSONResponse(configuration).body) - assert canonicalize(response_body) == canonicalize(configuration.json()) + assert canonicalize(response_body) == canonicalize(configuration.model_dump_json()) assert "target" not in json.loads(response_body)["nodes"] diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_gateway_replicas.py b/src/tests/_internal/server/background/pipeline_tasks/test_gateway_replicas.py index 6665d243df..e3af1c699a 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_gateway_replicas.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_gateway_replicas.py @@ -95,7 +95,7 @@ async def test_fetch_selects_eligible_replicas_and_sets_lock_fields( region=None, status=GatewayReplicaStatus.SUBMITTED, last_processed_at=stale - timedelta(seconds=3), - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) provisioning = await create_gateway_compute( session=session, @@ -131,7 +131,7 @@ async def test_fetch_selects_eligible_replicas_and_sets_lock_fields( instance_id=None, region=None, last_processed_at=now, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) recent.created_at = now - timedelta(minutes=2) recent.last_processed_at = now @@ -143,7 +143,7 @@ async def test_fetch_selects_eligible_replicas_and_sets_lock_fields( instance_id=None, region=None, last_processed_at=stale + timedelta(seconds=1), - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) locked.lock_expires_at = now + timedelta(minutes=1) locked.lock_token = uuid.uuid4() @@ -335,7 +335,7 @@ async def test_submitted_to_provisioning( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -379,7 +379,7 @@ async def test_submitted_backend_error_marks_terminated( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -417,7 +417,7 @@ async def test_submitted_backend_not_available_marks_terminated( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -453,7 +453,7 @@ async def test_submitted_skips_provisioning_if_gateway_to_be_deleted( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -488,7 +488,7 @@ async def test_submitted_skips_provisioning_if_gateway_failed( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -523,7 +523,7 @@ async def test_submitted_unexpected_error_marks_terminated( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) _lock_compute(compute) await session.commit() @@ -562,7 +562,7 @@ async def test_submitted_to_terminated_when_scaled_in( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) compute.scale_in = True _lock_compute(compute) diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_gateways.py b/src/tests/_internal/server/background/pipeline_tasks/test_gateways.py index 9de2f6acb2..e8c8b45a54 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_gateways.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_gateways.py @@ -314,7 +314,7 @@ async def test_fetch_includes_running_gateway_with_pending_scale_attempt_even_if instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) gateway.replica_scale_attempt = 1 await session.commit() @@ -885,7 +885,7 @@ async def test_still_provisioning_with_submitted_replica( instance_id=None, region=None, status=GatewayReplicaStatus.SUBMITTED, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) gateway.lock_token = uuid.uuid4() gateway.lock_expires_at = datetime(2025, 1, 2, 3, 4, tzinfo=timezone.utc) @@ -1237,7 +1237,7 @@ async def test_scale_in_prefers_less_advanced_replicas_over_older_running_ones( ip_address=None, instance_id=None, region=None, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) submitted.created_at = datetime(2025, 1, 2) gateway.lock_token = uuid.uuid4() @@ -1425,7 +1425,7 @@ async def test_attempt_counter_not_reset_while_replacement_replica_still_provisi region=None, status=GatewayReplicaStatus.PROVISIONING, replica_num=0, - configuration=get_gateway_compute_configuration().json(), + configuration=get_gateway_compute_configuration().model_dump_json(), ) gateway.replica_scale_attempt = 2 gateway.lock_token = uuid.uuid4() diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_instances/test_check.py b/src/tests/_internal/server/background/pipeline_tasks/test_instances/test_check.py index 90bb351f96..4ff57e0fd6 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_instances/test_check.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_instances/test_check.py @@ -10,6 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from dstack._internal.core.models.common import validate_json_extra_ignore from dstack._internal.core.models.fleets import FleetNodesSpec from dstack._internal.core.models.health import HealthStatus from dstack._internal.core.models.instances import ( @@ -201,7 +202,7 @@ async def test_check_shim_stores_gpu_driver( assert check_instance_inner_mock.call_args.kwargs["check_instance_info"] assert instance.job_provisioning_data is not None - jpd = JobProvisioningData.__response__.parse_raw(instance.job_provisioning_data) + jpd = validate_json_extra_ignore(JobProvisioningData, instance.job_provisioning_data) assert jpd.gpu_driver is not None assert jpd.gpu_driver.vendor == AcceleratorVendor.NVIDIA assert jpd.gpu_driver.version == "570.86.15" @@ -481,7 +482,7 @@ async def test_check_shim_check_instance_health( res = await session.execute(select(InstanceHealthCheckModel)) health_check = res.scalars().one() assert health_check.status == HealthStatus.WARNING - assert health_check.response == health_response.json() + assert health_check.response == health_response.model_dump_json() @pytest.mark.asyncio @@ -1063,7 +1064,9 @@ def test_sets_new_or_changed_driver(self, current_version): gpu_driver=GpuDriverInfo(vendor=AcceleratorVendor.NVIDIA, version="570.86.15"), ) assert "job_provisioning_data" in update_map - parsed = JobProvisioningData.__response__.parse_raw(update_map["job_provisioning_data"]) + parsed = validate_json_extra_ignore( + JobProvisioningData, update_map["job_provisioning_data"] + ) assert parsed.gpu_driver is not None assert parsed.gpu_driver.version == "570.86.15" diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_running_jobs.py b/src/tests/_internal/server/background/pipeline_tasks/test_running_jobs.py index 737a6d4dcc..ffb7d18028 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_running_jobs.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_running_jobs.py @@ -16,13 +16,14 @@ from dstack._internal import settings from dstack._internal.core.errors import SSHError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import NetworkMode +from dstack._internal.core.models.common import NetworkMode, validate_json_extra_ignore from dstack._internal.core.models.configurations import ( DevEnvironmentConfiguration, ProbeConfig, ServiceConfiguration, TaskConfiguration, ) +from dstack._internal.core.models.duration import Duration from dstack._internal.core.models.gateways import GatewayStatus from dstack._internal.core.models.instances import InstanceStatus from dstack._internal.core.models.profiles import StartupOrder, UtilizationPolicy @@ -89,7 +90,7 @@ get_volume_configuration, list_events, ) -from dstack._internal.utils.common import get_current_datetime +from dstack._internal.utils.common import get_current_datetime, get_or_error pytestmark = pytest.mark.usefixtures("image_config_mock", "test_log_storage") @@ -594,7 +595,9 @@ async def test_runs_provisioning_job( assert job.lock_expires_at is None assert job.lock_owner is None assert job.last_processed_at > before_processed_at - job_runtime_data = JobRuntimeData.__response__.parse_raw(job.job_runtime_data) + job_runtime_data = validate_json_extra_ignore( + JobRuntimeData, get_or_error(job.job_runtime_data) + ) assert job_runtime_data.working_dir == "/dstack/run" assert job_runtime_data.username == "dstack" @@ -829,7 +832,9 @@ async def test_pulling_shim( runner_client_mock.run_job.assert_called_once() await session.refresh(job) assert job.status == JobStatus.RUNNING - job_runtime_data = JobRuntimeData.__response__.parse_raw(job.job_runtime_data) + job_runtime_data = validate_json_extra_ignore( + JobRuntimeData, get_or_error(job.job_runtime_data) + ) assert job_runtime_data.ports == {10022: 32771, 10999: 32772} assert job_runtime_data.working_dir == "/dstack/run" assert job_runtime_data.username == "dstack" @@ -1227,7 +1232,9 @@ def assert_submit_job_to_runner(_, __, job_runtime_data, **kwargs): await session.refresh(job) assert job.status == JobStatus.PULLING - job_runtime_data = JobRuntimeData.__response__.parse_raw(job.job_runtime_data) + job_runtime_data = validate_json_extra_ignore( + JobRuntimeData, get_or_error(job.job_runtime_data) + ) assert job_runtime_data.ports == expected_ports async def test_pulling_shim_failed( @@ -1314,7 +1321,7 @@ async def test_pulling_shim_stores_pull_progress( await session.refresh(job) assert job.status == JobStatus.PULLING - assert job.image_pull_progress == progress.json() + assert job.image_pull_progress == progress.model_dump_json() async def test_provisioning_shim_force_stop_if_already_running_api_v1( self, @@ -1800,7 +1807,7 @@ async def test_gpu_utilization( ide="vscode", utilization_policy=UtilizationPolicy( min_gpu_utilization=80, - time_window=600, + time_window=Duration(600), ), ), ), @@ -2453,7 +2460,7 @@ async def test_provisioning_shim_uses_server_default_registry( def _router_service_configuration(router_type: str) -> ServiceConfiguration: - return ServiceConfiguration.parse_obj( + return ServiceConfiguration.model_validate( { "type": "service", "port": 8000, @@ -2510,7 +2517,7 @@ async def test_router_failed_terminates_worker(self): result.job_update_map.get("termination_reason_message") or "" ) - @freeze_time("2023-01-01 12:00:00+00:00") + @freeze_time("2023-01-01 12:00:00Z") async def test_router_not_provisioned_within_timeout_defers(self): context = self._make_context( submitted_at=datetime(2023, 1, 1, 11, 45, 0, tzinfo=timezone.utc), @@ -2524,7 +2531,7 @@ async def test_router_not_provisioned_within_timeout_defers(self): assert out is None assert result.job_update_map == {} - @freeze_time("2023-01-01 12:00:00+00:00") + @freeze_time("2023-01-01 12:00:00Z") async def test_router_not_provisioned_past_timeout_terminates(self): context = self._make_context( submitted_at=datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), @@ -2627,7 +2634,7 @@ async def test_dynamo_run_loads_all_non_terminated_replicas( status=JobStatus.PROVISIONING, ) run_id = run.id - parsed = RunSpec.__response__.parse_raw(run.run_spec) + parsed = validate_json_extra_ignore(RunSpec, get_or_error(run.run_spec)) await session.commit() session.expire_all() run_model = await _fetch_run_model( @@ -2663,7 +2670,7 @@ async def test_non_dynamo_loads_only_own_replica(self, test_db, session: AsyncSe status=JobStatus.PROVISIONING, ) run_id = run.id - parsed = RunSpec.__response__.parse_raw(run.run_spec) + parsed = validate_json_extra_ignore(RunSpec, get_or_error(run.run_spec)) await session.commit() session.expire_all() run_model = await _fetch_run_model( diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_runs/test_active.py b/src/tests/_internal/server/background/pipeline_tasks/test_runs/test_active.py index 8c91fce0ba..470b7824d9 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_runs/test_active.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_runs/test_active.py @@ -12,6 +12,7 @@ ServiceConfiguration, TaskConfiguration, ) +from dstack._internal.core.models.duration import Duration from dstack._internal.core.models.instances import InstanceStatus from dstack._internal.core.models.profiles import ( Profile, @@ -185,7 +186,7 @@ async def test_retries_failed_replica_within_retry_duration( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.ERROR]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.ERROR]), ), ) run = await create_run( @@ -227,7 +228,7 @@ async def test_retries_no_capacity_replica_and_keeps_service_running( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.INTERRUPTION]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.INTERRUPTION]), ), configuration=ServiceConfiguration( port=8080, @@ -306,7 +307,7 @@ async def test_replica_retry_deletes_superseded_no_capacity_submissions( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.INTERRUPTION]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.INTERRUPTION]), ), configuration=ServiceConfiguration( port=8080, @@ -386,7 +387,7 @@ async def test_retries_scheduled_run_no_capacity_from_trigger_time( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.NO_CAPACITY]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.NO_CAPACITY]), ), configuration=TaskConfiguration( commands=["echo hello"], @@ -435,7 +436,7 @@ async def test_terminates_scheduled_run_when_no_capacity_retry_exceeded_from_tri repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=600, on_events=[RetryEvent.NO_CAPACITY]), + retry=ProfileRetry(duration=Duration(600), on_events=[RetryEvent.NO_CAPACITY]), ), configuration=TaskConfiguration( commands=["echo hello"], @@ -484,7 +485,7 @@ async def test_retrying_multinode_replica_terminates_active_sibling_jobs( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.ERROR]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.ERROR]), ), configuration=TaskConfiguration( commands=["echo hello"], @@ -548,7 +549,7 @@ async def test_transitions_to_pending_when_retry_duration_exceeded( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=60, on_events=[RetryEvent.ERROR]), + retry=ProfileRetry(duration=Duration(60), on_events=[RetryEvent.ERROR]), ), ) run = await create_run( @@ -984,7 +985,7 @@ async def test_service_rolling_deployment_scale_up( # cannot be applied and rolling deployment is triggered instead. old_spec = get_job_spec(old_job) old_spec.commands = ["echo old!"] - old_job.job_spec_data = old_spec.json() + old_job.job_spec_data = old_spec.model_dump_json() await session.commit() lock_run(run) @@ -1053,7 +1054,7 @@ async def test_service_rolling_deployment_scale_down_old_unregistered( ) old_spec = get_job_spec(old_job) old_spec.commands = ["echo old!"] - old_job.job_spec_data = old_spec.json() + old_job.job_spec_data = old_spec.model_dump_json() await session.commit() lock_run(run) @@ -1111,7 +1112,7 @@ async def test_service_removed_group_cleanup( # Patch the job spec to have replica_group="old" old_spec = get_job_spec(old_group_job) old_spec.replica_group = "old" - old_group_job.job_spec_data = old_spec.json() + old_group_job.job_spec_data = old_spec.model_dump_json() await session.commit() lock_run(run) diff --git a/src/tests/_internal/server/background/pipeline_tasks/test_submitted_jobs.py b/src/tests/_internal/server/background/pipeline_tasks/test_submitted_jobs.py index 30c038945d..ee34ec5ad5 100644 --- a/src/tests/_internal/server/background/pipeline_tasks/test_submitted_jobs.py +++ b/src/tests/_internal/server/background/pipeline_tasks/test_submitted_jobs.py @@ -11,7 +11,12 @@ from dstack._internal.core.errors import BackendError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.common import EntityReference, NetworkMode, RegistryAuth +from dstack._internal.core.models.common import ( + EntityReference, + NetworkMode, + RegistryAuth, + validate_json_extra_ignore, +) from dstack._internal.core.models.configurations import ServiceConfiguration, TaskConfiguration from dstack._internal.core.models.envs import Env from dstack._internal.core.models.fleets import FleetNodesSpec, InstanceGroupPlacement @@ -74,7 +79,7 @@ get_ssh_fleet_configuration, get_volume_provisioning_data, ) -from dstack._internal.utils.common import get_current_datetime +from dstack._internal.utils.common import get_current_datetime, get_or_error pytestmark = pytest.mark.usefixtures("image_config_mock") @@ -1548,8 +1553,12 @@ async def test_assigns_multinode_jobs_to_specific_shared_ssh_instances( assert worker_job.instance is not None and worker_job.instance.id == selected_worker.id assert selected_master.busy_blocks == 2 assert selected_worker.busy_blocks == 2 - master_runtime = JobRuntimeData.__response__.parse_raw(master_job.job_runtime_data) - worker_runtime = JobRuntimeData.__response__.parse_raw(worker_job.job_runtime_data) + master_runtime = validate_json_extra_ignore( + JobRuntimeData, get_or_error(master_job.job_runtime_data) + ) + worker_runtime = validate_json_extra_ignore( + JobRuntimeData, get_or_error(worker_job.job_runtime_data) + ) assert master_runtime.network_mode == NetworkMode.HOST assert worker_runtime.network_mode == NetworkMode.HOST assert master_runtime.offer is not None and master_runtime.offer.blocks == 2 @@ -2566,7 +2575,7 @@ async def test_interpolates_secrets_when_provisioning_new_capacity( repo_id=repo.name, configuration=TaskConfiguration( image="ubuntu", - env=Env.parse_obj({"TOKEN": "${{ secrets.token }}"}), + env=Env.model_validate({"TOKEN": "${{ secrets.token }}"}), registry_auth=RegistryAuth( username="${{ secrets.registry_user }}", password="${{ secrets.registry_pass }}", diff --git a/src/tests/_internal/server/background/scheduled_tasks/test_idle_volumes.py b/src/tests/_internal/server/background/scheduled_tasks/test_idle_volumes.py index 86aeea0f4c..e2db9f4254 100644 --- a/src/tests/_internal/server/background/scheduled_tasks/test_idle_volumes.py +++ b/src/tests/_internal/server/background/scheduled_tasks/test_idle_volumes.py @@ -173,8 +173,7 @@ async def test_volume_attached(self, test_db, session: AsyncSession): project = await create_project(session=session) user = await create_user(session=session) - config = get_volume_configuration(name="test-volume") - config.auto_cleanup_duration = "1h" + config = get_volume_configuration(name="test-volume", auto_cleanup_duration=3600) volume = await create_volume( session=session, @@ -198,8 +197,7 @@ async def test_idle_duration_threshold(self, test_db, session: AsyncSession): project = await create_project(session=session) user = await create_user(session=session) - config = get_volume_configuration(name="test-volume") - config.auto_cleanup_duration = "1h" + config = get_volume_configuration(name="test-volume", auto_cleanup_duration=3600) volume = await create_volume( session=session, diff --git a/src/tests/_internal/server/conftest.py b/src/tests/_internal/server/conftest.py index 3894ac3601..125cc5de17 100644 --- a/src/tests/_internal/server/conftest.py +++ b/src/tests/_internal/server/conftest.py @@ -17,6 +17,33 @@ ) +def _warm_up_route_schemas() -> None: + """ + Build every route's pydantic schemas once, at import time. + + FastAPI builds a route's dependant lazily, on the first request that matches it. Several tests + make that first request inside `@freeze_time`, where `datetime.datetime` is freezegun's + `FakeDatetime`. pydantic v2 matches `datetime` by exact type and rejects the subclass, so a + route with an `Optional[datetime]` query parameter (`after`, `before`) fails to build with + `PydanticSchemaGenerationError` — and because `FakeDatetime` mimics `datetime`'s repr, the + error names `datetime.datetime` and reads like a production bug. + + Doing this eagerly, before any test freezes the clock, keeps the schemas built from the real + types. Nothing in production freezes time, so this is a test-environment fix only. + """ + # FastAPI 0.141 defers this to `_IncludedRouter.effective_candidates()`, reached from + # `matches()` on the first request. Duck-typed rather than importing the private class. + pending = list(app.routes) + while pending: + route = pending.pop() + build = getattr(route, "effective_candidates", None) + if callable(build): + pending.extend(build()) + + +_warm_up_route_schemas() + + @pytest.fixture def client(): transport = httpx.ASGITransport(app=app) @@ -34,7 +61,9 @@ def test_log_storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FileLog @pytest.fixture def image_config_mock(monkeypatch: pytest.MonkeyPatch) -> ImageConfig: - image_config = ImageConfig.parse_obj({"User": None, "Entrypoint": None, "Cmd": ["/bin/bash"]}) + image_config = ImageConfig.model_validate( + {"User": None, "Entrypoint": None, "Cmd": ["/bin/bash"]} + ) monkeypatch.setattr( "dstack._internal.server.services.jobs.configurators.base._get_image_config", Mock(return_value=image_config), diff --git a/src/tests/_internal/server/routers/test_events.py b/src/tests/_internal/server/routers/test_events.py index 7dd6359af1..eed7015ec7 100644 --- a/src/tests/_internal/server/routers/test_events.py +++ b/src/tests/_internal/server/routers/test_events.py @@ -71,7 +71,7 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) { "id": str(event_ids[1]), "message": "Project updated", - "recorded_at": "2026-01-01T12:00:01+00:00", + "recorded_at": "2026-01-01T12:00:01Z", "actor_user_id": None, "actor_user": None, "is_actor_user_deleted": None, @@ -89,7 +89,7 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) { "id": str(event_ids[0]), "message": "User added to project", - "recorded_at": "2026-01-01T12:00:00+00:00", + "recorded_at": "2026-01-01T12:00:00Z", "actor_user_id": str(user.id), "actor_user": "test_user", "is_actor_user_deleted": False, diff --git a/src/tests/_internal/server/routers/test_fleets.py b/src/tests/_internal/server/routers/test_fleets.py index b7a48b54df..c8f5507796 100644 --- a/src/tests/_internal/server/routers/test_fleets.py +++ b/src/tests/_internal/server/routers/test_fleets.py @@ -392,7 +392,7 @@ async def test_lists_fleets(self, test_db, session: AsyncSession, client: AsyncC "name": fleet.name, "project_name": project.name, "spec": json.loads(fleet.spec), - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": fleet.status.value, "status_message": None, "instances": [], @@ -628,7 +628,7 @@ async def test_returns_fleet_by_id( "name": fleet.name, "project_name": project.name, "spec": json.loads(fleet.spec), - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": fleet.status.value, "status_message": None, "instances": [], @@ -670,7 +670,7 @@ async def test_returns_not_deleted_fleet_by_name( "name": active_fleet.name, "project_name": project.name, "spec": json.loads(active_fleet.spec), - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": active_fleet.status.value, "status_message": None, "instances": [], @@ -923,7 +923,7 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 200 assert response.json() == { @@ -979,7 +979,7 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async }, "autocreated": False, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": "active", "status_message": None, "instances": [ @@ -997,7 +997,7 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async "health_status": "healthy", "termination_reason": None, "termination_reason_message": None, - "created": "2023-01-02T03:04:00+00:00", + "created": "2023-01-02T03:04:00Z", "finished_at": None, "backend": None, "region": None, @@ -1037,7 +1037,7 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 200, response.json() assert response.json() == { @@ -1101,7 +1101,7 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A }, "autocreated": False, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": "active", "status_message": None, "instances": [ @@ -1132,7 +1132,7 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A "health_status": "healthy", "termination_reason": None, "termination_reason_message": None, - "created": "2023-01-02T03:04:00+00:00", + "created": "2023-01-02T03:04:00Z", "finished_at": None, "region": "remote", "availability_zone": None, @@ -1192,7 +1192,7 @@ async def test_creates_ssh_fleet_with_blocks( response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 200, response.json() res = await session.execute(select(FleetModel)) @@ -1218,7 +1218,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A network=None, ) current_spec = get_fleet_spec(conf=current_conf) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) # 10.0.0.100 removed, 10.0.0.101 added spec.configuration.ssh_config.hosts = ["10.0.0.101"] @@ -1253,7 +1253,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A headers=get_auth_headers(user.token), json={ "plan": { - "spec": spec.dict(), + "spec": spec.model_dump(), "current_resource": _fleet_model_to_json_dict(fleet), }, "force": False, @@ -1322,7 +1322,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A }, "autocreated": False, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "status": "active", "status_message": None, "instances": [ @@ -1353,7 +1353,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A "health_status": "healthy", "termination_reason": "terminated_by_user", "termination_reason_message": None, - "created": "2023-01-02T03:04:00+00:00", + "created": "2023-01-02T03:04:00Z", "finished_at": None, "region": "remote", "availability_zone": None, @@ -1389,7 +1389,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A "health_status": "healthy", "termination_reason": None, "termination_reason_message": None, - "created": "2023-01-02T03:04:00+00:00", + "created": "2023-01-02T03:04:00Z", "finished_at": None, "region": "remote", "availability_zone": None, @@ -1436,7 +1436,7 @@ async def test_updates_cloud_fleet_nodes_in_place_when_fleet_in_use( status=InstanceStatus.BUSY, instance_num=0, ) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) spec.configuration.nodes = FleetNodesSpec(min=1, target=1, max=3) response = await client.post( @@ -1444,7 +1444,7 @@ async def test_updates_cloud_fleet_nodes_in_place_when_fleet_in_use( headers=get_auth_headers(user.token), json={ "plan": { - "spec": spec.dict(), + "spec": spec.model_dump(), "current_resource": _fleet_model_to_json_dict(fleet), }, "force": False, @@ -1475,7 +1475,7 @@ async def test_updates_cloud_fleet_nodes_target_without_changing_instance_count( conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=0, max=1)) ) fleet = await create_fleet(session=session, project=project, spec=current_spec) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) spec.configuration.nodes = FleetNodesSpec(min=0, target=1, max=1) response = await client.post( @@ -1483,7 +1483,7 @@ async def test_updates_cloud_fleet_nodes_target_without_changing_instance_count( headers=get_auth_headers(user.token), json={ "plan": { - "spec": spec.dict(), + "spec": spec.model_dump(), "current_resource": _fleet_model_to_json_dict(fleet), }, "force": False, @@ -1538,7 +1538,7 @@ async def test_errors_if_ssh_key_is_bad( response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 400 @@ -1573,7 +1573,7 @@ async def test_errors_if_ssh_fleet_uses_backend_only_field( response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 400, response.json() assert response.json()["detail"][0]["msg"] == ( @@ -1583,7 +1583,7 @@ async def test_errors_if_ssh_fleet_uses_backend_only_field( @pytest.mark.parametrize( ["field_name", "field_value"], [ - pytest.param("env", Env.parse_obj({"K": "V"}), id="env"), + pytest.param("env", Env.model_validate({"K": "V"}), id="env"), ], ) @pytest.mark.asyncio @@ -1607,7 +1607,7 @@ async def test_errors_if_backend_fleet_uses_ssh_only_field( response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 400, response.json() assert response.json()["detail"][0]["msg"] == ( @@ -1641,7 +1641,7 @@ async def test_forbids_if_no_permission_to_manage_ssh_fleets( response = await client.post( f"/api/project/{project.name}/fleets/apply", headers=get_auth_headers(user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code in [401, 403] @@ -1678,7 +1678,7 @@ async def test_importer_member_cannot_apply_plan_on_imported_fleet( response = await client.post( f"/api/project/{exporter_project.name}/fleets/apply", headers=get_auth_headers(importer_user.token), - json={"plan": {"spec": spec.dict()}, "force": False}, + json={"plan": {"spec": spec.model_dump()}, "force": False}, ) assert response.status_code == 403 @@ -2224,7 +2224,7 @@ async def test_returns_create_plan_for_new_fleet( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) backend_mock.compute.return_value.get_offers.assert_called_once() @@ -2232,10 +2232,10 @@ async def test_returns_create_plan_for_new_fleet( assert response.json() == { "project_name": project.name, "user": user.name, - "spec": json.loads(spec.json()), - "effective_spec": json.loads(spec.json()), + "spec": json.loads(spec.model_dump_json()), + "effective_spec": json.loads(spec.model_dump_json()), "current_resource": None, - "offers": [json.loads(o.json()) for o in offers], + "offers": [json.loads(o.model_dump_json()) for o in offers], "total_offers": len(offers), "max_offer_price": 1.0, "action": "create", @@ -2267,13 +2267,13 @@ async def test_returns_offers_for_elastic_container_backend_fleet( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) backend_mock.compute.return_value.get_offers.assert_called_once() response_json = response.json() assert response.status_code == 200, response_json - assert response_json["offers"] == [json.loads(offer.json())] + assert response_json["offers"] == [json.loads(offer.model_dump_json())] assert response_json["total_offers"] == 1 assert response_json["max_offer_price"] == offer.price @@ -2303,7 +2303,7 @@ async def test_returns_no_offers_for_non_elastic_container_backend_fleet( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) backend_mock.compute.return_value.get_offers.assert_called_once() @@ -2325,9 +2325,9 @@ async def test_returns_update_plan_for_existing_fleet( ) conf = get_ssh_fleet_configuration(hosts=["10.0.0.100"]) spec = get_fleet_spec(conf=conf) - effective_spec = spec.copy(deep=True) + effective_spec = spec.model_copy(deep=True) effective_spec.configuration.ssh_config.ssh_key = None - current_spec = spec.copy(deep=True) + current_spec = spec.model_copy(deep=True) # `hosts` can be updated in-place current_spec.configuration.ssh_config.hosts = ["10.0.0.100", "10.0.0.101"] fleet = await create_fleet(session=session, project=project, spec=current_spec) @@ -2335,15 +2335,15 @@ async def test_returns_update_plan_for_existing_fleet( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) assert response.status_code == 200 assert response.json() == { "project_name": project.name, "user": user.name, - "spec": spec.dict(), - "effective_spec": effective_spec.dict(), + "spec": spec.model_dump(), + "effective_spec": effective_spec.model_dump(), "current_resource": _fleet_model_to_json_dict(fleet), "offers": [], "total_offers": 0, @@ -2364,14 +2364,14 @@ async def test_returns_update_plan_for_existing_cloud_fleet_nodes_update( current_spec = get_fleet_spec( conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=0, max=1)) ) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) spec.configuration.nodes = FleetNodesSpec(min=1, target=1, max=1) fleet = await create_fleet(session=session, project=project, spec=current_spec) response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) response_json = response.json() @@ -2392,14 +2392,14 @@ async def test_returns_create_plan_for_existing_cloud_fleet_blocks_update( current_spec = get_fleet_spec( conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=0, max=1)) ) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) spec.configuration.blocks = 2 fleet = await create_fleet(session=session, project=project, spec=current_spec) response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) response_json = response.json() @@ -2420,7 +2420,7 @@ async def test_returns_update_plan_for_existing_cloud_fleet_provisioning_fields_ current_spec = get_fleet_spec( conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=0, max=1)) ) - spec = current_spec.copy(deep=True) + spec = current_spec.model_copy(deep=True) spec.configuration.backends = [BackendType.AWS] spec.configuration.regions = ["us-east-1"] fleet = await create_fleet(session=session, project=project, spec=current_spec) @@ -2428,7 +2428,7 @@ async def test_returns_update_plan_for_existing_cloud_fleet_provisioning_fields_ response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) response_json = response.json() @@ -2448,9 +2448,9 @@ async def test_returns_create_plan_for_existing_fleet( ) conf = get_ssh_fleet_configuration(placement=InstanceGroupPlacement.ANY) spec = get_fleet_spec(conf=conf) - effective_spec = spec.copy(deep=True) + effective_spec = spec.model_copy(deep=True) effective_spec.configuration.ssh_config.ssh_key = None - current_spec = spec.copy(deep=True) + current_spec = spec.model_copy(deep=True) # `placement` cannot be updated in-place current_spec.configuration.placement = InstanceGroupPlacement.CLUSTER fleet = await create_fleet(session=session, project=project, spec=current_spec) @@ -2458,15 +2458,15 @@ async def test_returns_create_plan_for_existing_fleet( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=get_auth_headers(user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) assert response.status_code == 200 assert response.json() == { "project_name": project.name, "user": user.name, - "spec": spec.dict(), - "effective_spec": effective_spec.dict(), + "spec": spec.model_dump(), + "effective_spec": effective_spec.model_dump(), "current_resource": _fleet_model_to_json_dict(fleet), "offers": [], "total_offers": 0, @@ -2527,7 +2527,7 @@ async def test_replaces_no_balance_with_not_available_for_old_clients( response = await client.post( f"/api/project/{project.name}/fleets/get_plan", headers=headers, - json={"spec": get_fleet_spec().dict()}, + json={"spec": get_fleet_spec().model_dump()}, ) assert response.status_code == 200 @@ -2565,10 +2565,10 @@ async def test_importer_member_cannot_get_plan_for_imported_fleet( response = await client.post( f"/api/project/{exporter_project.name}/fleets/get_plan", headers=get_auth_headers(importer_user.token), - json={"spec": spec.dict()}, + json={"spec": spec.model_dump()}, ) assert response.status_code == 403 def _fleet_model_to_json_dict(fleet: FleetModel) -> dict: - return json.loads(fleet_model_to_fleet(fleet).json()) + return json.loads(fleet_model_to_fleet(fleet).model_dump_json()) diff --git a/src/tests/_internal/server/routers/test_gpus.py b/src/tests/_internal/server/routers/test_gpus.py index 18cfee03db..85b38257e5 100644 --- a/src/tests/_internal/server/routers/test_gpus.py +++ b/src/tests/_internal/server/routers/test_gpus.py @@ -142,7 +142,7 @@ async def call_gpus_api( unallocated_resources: Optional[bool] = None, ): """Helper to call the GPUs API with standard parameters.""" - json_data = {"run_spec": run_spec.dict()} + json_data = {"run_spec": run_spec.model_dump()} if group_by is not None: json_data["group_by"] = group_by if full_offers is not None: @@ -439,7 +439,7 @@ async def test_returns_empty_gpus_when_no_offers( response = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200 @@ -465,7 +465,7 @@ async def test_invalid_group_by_rejected( response = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict(), "group_by": ["invalid_field"]}, + json={"run_spec": run_spec.model_dump(), "group_by": ["invalid_field"]}, ) assert response.status_code == 422 assert "validation error" in response.text.lower() or "invalid" in response.text.lower() @@ -602,7 +602,7 @@ async def test_exact_aggregation_values( response = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200 data = response.json() @@ -626,7 +626,7 @@ async def test_exact_aggregation_values( response_count_grouped = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict(), "group_by": ["count"]}, + json={"run_spec": run_spec.model_dump(), "group_by": ["count"]}, ) assert response_count_grouped.status_code == 200 count_grouped_data = response_count_grouped.json() @@ -666,7 +666,7 @@ async def test_exact_aggregation_values( response_backend = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict(), "group_by": ["backend"]}, + json={"run_spec": run_spec.model_dump(), "group_by": ["backend"]}, ) assert response_backend.status_code == 200 backend_data = response_backend.json() @@ -710,7 +710,7 @@ async def test_exact_aggregation_values( response_region = await client.post( f"/api/project/{project.name}/gpus/list", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict(), "group_by": ["backend", "region"]}, + json={"run_spec": run_spec.model_dump(), "group_by": ["backend", "region"]}, ) assert response_region.status_code == 200 region_data = response_region.json() diff --git a/src/tests/_internal/server/routers/test_instances.py b/src/tests/_internal/server/routers/test_instances.py index 439538c14c..43f0eb1c44 100644 --- a/src/tests/_internal/server/routers/test_instances.py +++ b/src/tests/_internal/server/routers/test_instances.py @@ -596,17 +596,17 @@ async def test_returns_health_checks(self, session: AsyncSession, client: AsyncC assert response.json() == { "health_checks": [ { - "collected_at": "2025-01-01T12:01:00+00:00", + "collected_at": "2025-01-01T12:01:00Z", "status": "failure", "events": [ { - "timestamp": "2025-01-01T12:01:00+00:00", + "timestamp": "2025-01-01T12:01:00Z", "status": "failure", "message": "Detected 333 volatile double-bit ECC error(s) in GPU 0.", } ], }, - {"collected_at": "2025-01-01T12:00:00+00:00", "status": "healthy", "events": []}, + {"collected_at": "2025-01-01T12:00:00Z", "status": "healthy", "events": []}, ] } diff --git a/src/tests/_internal/server/routers/test_logs.py b/src/tests/_internal/server/routers/test_logs.py index 33d904d56a..6c0d5e056c 100644 --- a/src/tests/_internal/server/routers/test_logs.py +++ b/src/tests/_internal/server/routers/test_logs.py @@ -43,9 +43,9 @@ async def test_returns_logs( ) runner_log_path.parent.mkdir(parents=True, exist_ok=True) runner_log_path.write_text( - '{"timestamp": "2023-10-06T10:01:53.234234+00:00", "log_source": "stdout", "message": "Hello"}\n' - '{"timestamp": "2023-10-06T10:01:53.234235+00:00", "log_source": "stdout", "message": "World"}\n' - '{"timestamp": "2023-10-06T10:01:53.234236+00:00", "log_source": "stdout", "message": "!"}\n' + '{"timestamp": "2023-10-06T10:01:53.234234Z", "log_source": "stdout", "message": "Hello"}\n' + '{"timestamp": "2023-10-06T10:01:53.234235Z", "log_source": "stdout", "message": "World"}\n' + '{"timestamp": "2023-10-06T10:01:53.234236Z", "log_source": "stdout", "message": "!"}\n' ) response = await client.post( f"/api/project/{project.name}/logs/poll", @@ -60,17 +60,17 @@ async def test_returns_logs( assert response.json() == { "logs": [ { - "timestamp": "2023-10-06T10:01:53.234234+00:00", + "timestamp": "2023-10-06T10:01:53.234234Z", "log_source": "stdout", "message": "SGVsbG8=", }, { - "timestamp": "2023-10-06T10:01:53.234235+00:00", + "timestamp": "2023-10-06T10:01:53.234235Z", "log_source": "stdout", "message": "V29ybGQ=", }, { - "timestamp": "2023-10-06T10:01:53.234236+00:00", + "timestamp": "2023-10-06T10:01:53.234236Z", "log_source": "stdout", "message": "IQ==", }, @@ -84,7 +84,7 @@ async def test_returns_logs( json={ "run_name": "test_run", "job_submission_id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e", - "start_time": "2023-10-06T10:01:53.234235+00:00", + "start_time": "2023-10-06T10:01:53.234235Z", "diagnose": True, }, ) @@ -92,7 +92,7 @@ async def test_returns_logs( assert response.json() == { "logs": [ { - "timestamp": "2023-10-06T10:01:53.234236+00:00", + "timestamp": "2023-10-06T10:01:53.234236Z", "log_source": "stdout", "message": "IQ==", }, diff --git a/src/tests/_internal/server/routers/test_metrics.py b/src/tests/_internal/server/routers/test_metrics.py index 19e849f413..d747c7ade7 100644 --- a/src/tests/_internal/server/routers/test_metrics.py +++ b/src/tests/_internal/server/routers/test_metrics.py @@ -107,47 +107,47 @@ async def test_returns_metrics(self, test_db, session: AsyncSession, client: Asy "metrics": [ { "name": "cpu_usage_percent", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [60], }, { "name": "memory_usage_bytes", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [1024], }, { "name": "memory_working_set_bytes", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [512], }, { "name": "cpus_detected_num", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [64], }, { "name": "memory_total_bytes", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [137438953472], }, { "name": "gpus_detected_num", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [1], }, { "name": "gpu_memory_total_bytes", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [34359738368], }, { "name": "gpu_memory_usage_bytes_gpu0", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [1024], }, { "name": "gpu_util_percent_gpu0", - "timestamps": ["2023-01-02T03:04:25+00:00"], + "timestamps": ["2023-01-02T03:04:25Z"], "values": [10], }, ] diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 67afa77390..b15fabfd6f 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -65,7 +65,7 @@ async def test_returns_projects(self, test_db, session: AsyncSession, client: As "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -74,7 +74,7 @@ async def test_returns_projects(self, test_db, session: AsyncSession, client: As }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "backends": [], "members": [], "is_public": False, @@ -245,7 +245,7 @@ async def test_returns_paginated_projects( "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:00:00+00:00", + "created_at": "2023-01-02T03:00:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -254,7 +254,7 @@ async def test_returns_paginated_projects( }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:06:00+00:00", + "created_at": "2023-01-02T03:06:00Z", "backends": [], "members": [], "is_public": False, @@ -265,7 +265,7 @@ async def test_returns_paginated_projects( "/api/projects/list", headers=get_auth_headers(user.token), json={ - "prev_created_at": "2023-01-02T03:06:00+00:00", + "prev_created_at": "2023-01-02T03:06:00Z", "prev_id": str(project3.id), "limit": 1, }, @@ -278,7 +278,7 @@ async def test_returns_paginated_projects( "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:00:00+00:00", + "created_at": "2023-01-02T03:00:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -287,7 +287,7 @@ async def test_returns_paginated_projects( }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", "backends": [], "members": [], "is_public": False, @@ -298,7 +298,7 @@ async def test_returns_paginated_projects( "/api/projects/list", headers=get_auth_headers(user.token), json={ - "prev_created_at": "2023-01-02T03:05:00+00:00", + "prev_created_at": "2023-01-02T03:05:00Z", "prev_id": str(project2.id), "limit": 1, }, @@ -311,7 +311,7 @@ async def test_returns_paginated_projects( "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:00:00+00:00", + "created_at": "2023-01-02T03:00:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -320,7 +320,7 @@ async def test_returns_paginated_projects( }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "backends": [], "members": [], "is_public": False, @@ -363,7 +363,7 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:00:00+00:00", + "created_at": "2023-01-02T03:00:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -372,7 +372,7 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", "backends": [], "members": [], "is_public": False, @@ -948,7 +948,7 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -957,14 +957,14 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy }, "ssh_public_key": user.ssh_public_key, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "backends": [], "members": [ { "user": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -1509,7 +1509,7 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy "owner": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -1518,14 +1518,14 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy }, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "backends": [], "members": [ { "user": { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "active": True, @@ -1756,7 +1756,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client "user": { "id": str(admin.id), "username": admin.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": admin.global_role, "email": None, "active": True, @@ -1775,7 +1775,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client "user": { "id": str(user1.id), "username": user1.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user1.global_role, "email": None, "active": True, @@ -1794,7 +1794,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client "user": { "id": str(user2.id), "username": user2.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user2.global_role, "email": None, "active": True, @@ -1852,7 +1852,7 @@ async def test_sets_project_members_by_email( "user": { "id": str(user1.id), "username": user1.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user1.global_role, "email": user1.email, "active": True, diff --git a/src/tests/_internal/server/routers/test_public_keys.py b/src/tests/_internal/server/routers/test_public_keys.py index 80954e0262..e4dee3895b 100644 --- a/src/tests/_internal/server/routers/test_public_keys.py +++ b/src/tests/_internal/server/routers/test_public_keys.py @@ -43,7 +43,7 @@ async def test_lists_own_public_keys(self, session: AsyncSession, client: AsyncC assert response.json() == [ { "id": str(key.id), - "added_at": "2023-01-02T03:04:00+00:00", + "added_at": "2023-01-02T03:04:00Z", "name": "my-key", "type": "ssh-ed25519", "fingerprint": "SHA256:testfingerprint", @@ -137,7 +137,7 @@ async def test_adds_valid_public_key( "type": "ssh-ed25519", "name": "test@example.com", "fingerprint": "SHA256:uALbfMqe7g4MMaRS5NMJen38dAEHwtxzR0iX0Ymuc80", - "added_at": "2023-01-02T03:04:00+00:00", + "added_at": "2023-01-02T03:04:00Z", } validate_openssh_public_key_mock.assert_awaited_once_with(self.PUBLIC_KEY) diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py index 0eeb4a11ac..46d530fff1 100644 --- a/src/tests/_internal/server/routers/test_runs.py +++ b/src/tests/_internal/server/routers/test_runs.py @@ -79,6 +79,7 @@ list_events, ) from dstack._internal.server.testing.matchers import SomeUUID4Str +from dstack._internal.utils.common import render_datetime_as_api pytestmark = pytest.mark.usefixtures("image_config_mock", "disable_sshproxy") @@ -187,7 +188,7 @@ def get_dev_env_run_plan_dict( "gpu": None, "shm_size": None, }, - "volumes": [json.loads(v.json()) for v in volumes], + "volumes": [json.loads(v.model_dump_json()) for v in volumes], "repos": [ { "url": "https://github.com/dstackai/dstack", @@ -303,7 +304,7 @@ def get_dev_env_run_plan_dict( "backend_options": None, }, "retry": None, - "volumes": volumes, + "volumes": [json.loads(v.model_dump_json()) for v in volumes], "ssh_key": None, "working_dir": None, "repo_code_hash": None, @@ -321,12 +322,12 @@ def get_dev_env_run_plan_dict( "service_port": None, "probes": [], }, - "offers": [json.loads(o.json()) for o in offers], + "offers": [json.loads(o.model_dump_json()) for o in offers], "total_offers": total_offers, "max_price": max_price, } ], - "current_resource": current_resource.dict() if current_resource else None, + "current_resource": current_resource.model_dump() if current_resource else None, "action": action.value, } @@ -338,9 +339,9 @@ def get_dev_env_run_dict( username: str = "test_user", run_name: Optional[str] = "run_name", repo_id: str = "test_repo", - submitted_at: str = "2023-01-02T03:04:00+00:00", - last_processed_at: str = "2023-01-02T03:04:00+00:00", - finished_at: Optional[str] = "2023-01-02T03:04:00+00:00", + submitted_at: str = "2023-01-02T03:04:00Z", + last_processed_at: str = "2023-01-02T03:04:00Z", + finished_at: Optional[str] = "2023-01-02T03:04:00Z", privileged: bool = False, docker: Optional[bool] = None, deleted: bool = False, @@ -695,14 +696,14 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli fleet=fleet, submitted_at=run1_submitted_at, ) - run1_spec = RunSpec.parse_raw(run1.run_spec) + run1_spec = RunSpec.model_validate_json(run1.run_spec) job = await create_job( session=session, run=run1, submitted_at=run1_submitted_at, last_processed_at=run1_submitted_at, ) - job_spec = JobSpec.parse_raw(job.job_spec_data) + job_spec = JobSpec.model_validate_json(job.job_spec_data) run2_submitted_at = datetime(2023, 1, 1, 3, 4, tzinfo=timezone.utc) run2 = await create_run( session=session, @@ -712,7 +713,7 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli fleet=fleet, submitted_at=run2_submitted_at, ) - run2_spec = RunSpec.parse_raw(run2.run_spec) + run2_spec = RunSpec.model_validate_json(run2.run_spec) response = await client.post( "/api/runs/list", headers=get_auth_headers(user.token), @@ -728,21 +729,21 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli "id": str(fleet.id), "name": fleet.name, }, - "submitted_at": run1_submitted_at.isoformat(), - "last_processed_at": run1_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run1_submitted_at), + "last_processed_at": render_datetime_as_api(run1_submitted_at), "status": "submitted", "status_message": "submitted", - "run_spec": run1_spec.dict(), + "run_spec": run1_spec.model_dump(), "jobs": [ { - "job_spec": job_spec.dict(), + "job_spec": job_spec.model_dump(), "job_submissions": [ { "id": str(job.id), "submission_num": 0, "deployment_num": 0, - "submitted_at": run1_submitted_at.isoformat(), - "last_processed_at": run1_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run1_submitted_at), + "last_processed_at": render_datetime_as_api(run1_submitted_at), "finished_at": None, "inactivity_secs": None, "status": "submitted", @@ -764,8 +765,8 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli "id": str(job.id), "submission_num": 0, "deployment_num": 0, - "submitted_at": run1_submitted_at.isoformat(), - "last_processed_at": run1_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run1_submitted_at), + "last_processed_at": render_datetime_as_api(run1_submitted_at), "finished_at": None, "inactivity_secs": None, "status": "submitted", @@ -779,7 +780,7 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli "probes": [], "image_pull_progress": None, }, - "cost": 0, + "cost": 0.0, "service": None, "deployment_num": 0, "termination_reason": None, @@ -795,14 +796,14 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli "id": str(fleet.id), "name": fleet.name, }, - "submitted_at": run2_submitted_at.isoformat(), - "last_processed_at": run2_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run2_submitted_at), + "last_processed_at": render_datetime_as_api(run2_submitted_at), "status": "submitted", "status_message": "submitted", - "run_spec": run2_spec.dict(), + "run_spec": run2_spec.model_dump(), "jobs": [], "latest_job_submission": None, - "cost": 0, + "cost": 0.0, "service": None, "deployment_num": 0, "termination_reason": None, @@ -895,7 +896,7 @@ async def test_limits_job_submissions( user=user, submitted_at=run_submitted_at, ) - run_spec = RunSpec.parse_raw(run.run_spec) + run_spec = RunSpec.model_validate_json(run.run_spec) await create_job( session=session, run=run, @@ -909,7 +910,7 @@ async def test_limits_job_submissions( submitted_at=run_submitted_at, last_processed_at=run_submitted_at, ) - job2_spec = JobSpec.parse_raw(job2.job_spec_data) + job2_spec = JobSpec.model_validate_json(job2.job_spec_data) response = await client.post( "/api/runs/list", headers=get_auth_headers(user.token), @@ -922,21 +923,21 @@ async def test_limits_job_submissions( "project_name": project.name, "user": user.name, "fleet": None, - "submitted_at": run_submitted_at.isoformat(), - "last_processed_at": run_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run_submitted_at), + "last_processed_at": render_datetime_as_api(run_submitted_at), "status": "submitted", "status_message": "submitted", - "run_spec": run_spec.dict(), + "run_spec": run_spec.model_dump(), "jobs": [ { - "job_spec": job2_spec.dict(), + "job_spec": job2_spec.model_dump(), "job_submissions": [ { "id": str(job2.id), "submission_num": 1, "deployment_num": 0, - "submitted_at": run_submitted_at.isoformat(), - "last_processed_at": run_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run_submitted_at), + "last_processed_at": render_datetime_as_api(run_submitted_at), "finished_at": None, "inactivity_secs": None, "status": "submitted", @@ -958,8 +959,8 @@ async def test_limits_job_submissions( "id": str(job2.id), "submission_num": 1, "deployment_num": 0, - "submitted_at": run_submitted_at.isoformat(), - "last_processed_at": run_submitted_at.isoformat(), + "submitted_at": render_datetime_as_api(run_submitted_at), + "last_processed_at": render_datetime_as_api(run_submitted_at), "finished_at": None, "inactivity_secs": None, "status": "submitted", @@ -973,7 +974,7 @@ async def test_limits_job_submissions( "probes": [], "image_pull_progress": None, }, - "cost": 0, + "cost": 0.0, "service": None, "deployment_num": 0, "termination_reason": None, @@ -1603,7 +1604,7 @@ async def test_forwards_full_offers_to_compute_get_offers( repo_id=repo.name, configuration=DevEnvironmentConfiguration(ide="vscode"), ) - body: dict = {"run_spec": json.loads(run_spec.json())} + body: dict = {"run_spec": json.loads(run_spec.model_dump_json())} if body_full_offers is not None: body["full_offers"] = body_full_offers @@ -1658,7 +1659,7 @@ async def test_forwards_unallocated_resources_to_compute_get_offers( repo_id=repo.name, configuration=DevEnvironmentConfiguration(ide="vscode"), ) - body: dict = {"run_spec": json.loads(run_spec.json())} + body: dict = {"run_spec": json.loads(run_spec.model_dump_json())} if body_unallocated_resources is not None: body["unallocated_resources"] = body_unallocated_resources @@ -1774,7 +1775,7 @@ async def test_task_with_two_nodes_returns_two_job_plans( repo_id=repo.name, configuration=TaskConfiguration(commands=["echo hi"], nodes=2), ) - body = {"run_spec": json.loads(run_spec.json())} + body = {"run_spec": json.loads(run_spec.model_dump_json())} with patch("dstack._internal.server.services.backends.get_project_backends") as m: backend_mock = Mock() backend_mock.TYPE = BackendType.AWS @@ -1853,7 +1854,7 @@ async def test_service_with_two_replica_groups_returns_two_job_plans( ], ), ) - body = {"run_spec": json.loads(run_spec.json())} + body = {"run_spec": json.loads(run_spec.model_dump_json())} def offers_by_requirements( requirements: Requirements, full_offers: bool, unallocated_resources: bool @@ -1914,7 +1915,7 @@ async def test_service_reservation_group_filters_backends_by_reservation_support ], ), ) - body = {"run_spec": json.loads(run_spec.json())} + body = {"run_spec": json.loads(run_spec.model_dump_json())} with patch("dstack._internal.server.services.backends.get_project_backends") as m: aws_backend_mock = Mock() @@ -2371,7 +2372,7 @@ async def test_preserves_backend_specific_offer_order( run_spec = get_run_spec( repo_id=repo.name, configuration=parse_run_configuration(configuration) ) - body = {"run_spec": run_spec.dict()} + body = {"run_spec": run_spec.model_dump()} backend_mock_aws = Mock() backend_mock_aws.TYPE = BackendType.AWS @@ -2442,7 +2443,7 @@ async def test_offer_cli_preserves_backend_specific_offer_order_across_fleets( fleets=["fleet-aws", "fleet-vastai"], ), ) - body = {"run_spec": run_spec.dict()} + body = {"run_spec": run_spec.model_dump()} backend_mock_aws = Mock() backend_mock_aws.TYPE = BackendType.AWS @@ -2543,7 +2544,7 @@ async def test_offer_cli_returns_offers_from_all_specified_fleets( response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -2590,7 +2591,7 @@ async def test_offer_cli_deduplicates_identical_backend_offers_across_specified_ fleets=["fleet-a", "fleet-b"], ), ) - body = {"run_spec": run_spec.dict()} + body = {"run_spec": run_spec.model_dump()} with patch("dstack._internal.server.services.backends.get_project_backends") as m: backend_mock_aws = Mock() @@ -2678,7 +2679,7 @@ async def test_offer_cli_keeps_identical_existing_instances_from_specified_fleet response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -2712,7 +2713,7 @@ async def test_offer_cli_without_fleet_keeps_global_offers( user="root", ), ) - body = {"run_spec": run_spec.dict()} + body = {"run_spec": run_spec.model_dump()} with patch("dstack._internal.server.services.backends.get_project_backends") as m: backend_mock_aws = Mock() backend_mock_aws.TYPE = BackendType.AWS @@ -2804,7 +2805,7 @@ async def test_offer_without_fleets_uses_global_offer_collection( response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -2867,7 +2868,7 @@ async def test_offer_with_fleets_uses_selected_fleet_offer_collection( response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -2931,7 +2932,7 @@ async def test_regular_run_plan_uses_best_fleet_candidate_selection( response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -3110,12 +3111,12 @@ async def test_returns_update_or_create_action_on_conf_change( response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200 response_json = response.json() assert response_json["action"] == action - assert response_json["current_resource"] == json.loads(run.json()) + assert response_json["current_resource"] == json.loads(run.model_dump_json()) @pytest.mark.asyncio @pytest.mark.usefixtures("test_db") @@ -3133,7 +3134,7 @@ async def test_generates_user_ssh_key(self, session: AsyncSession, client: Async response = await client.post( f"/api/project/{project.name}/runs/get_plan", headers=get_auth_headers(user.token), - json={"run_spec": run_spec.dict()}, + json={"run_spec": run_spec.model_dump()}, ) assert response.status_code == 200, response.json() @@ -3184,7 +3185,7 @@ async def test_patches_service_configuration_probes_for_old_clients( run_name="test-service", ) - body = {"run_spec": run_spec.dict()} + body = {"run_spec": run_spec.model_dump()} headers = get_auth_headers(user.token) if client_version is not None: headers["X-API-Version"] = client_version @@ -3228,7 +3229,7 @@ async def test_submits_new_run_if_no_current_resource( session=session, project=project, user=user, project_role=ProjectRole.USER ) submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) - submitted_at_formatted = "2023-01-02T03:04:00+00:00" + submitted_at_formatted = "2023-01-02T03:04:00Z" last_processed_at_formatted = submitted_at_formatted repo = await create_repo(session=session, project_id=project.id) run_dict = get_dev_env_run_dict( @@ -3296,7 +3297,7 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl ) run = run_model_to_run(run_model) run_spec.configuration_path = "new.dstack.yml" - run_spec.configuration.replicas = Range(min=2, max=2) + run_spec.configuration.replicas = Range[int](min=2, max=2) response = await client.post( f"/api/project/{project.name}/runs/apply", headers=get_auth_headers(user.token), @@ -3308,7 +3309,7 @@ async def test_updates_run(self, test_db, session: AsyncSession, client: AsyncCl current_resource=run, ), force=False, - ).json() + ).model_dump_json() ), ) assert response.status_code == 200, response.json() @@ -3348,7 +3349,7 @@ async def test_creates_pending_run_if_run_is_scheduled( headers=get_auth_headers(user.token), json={ "plan": { - "run_spec": json.loads(run_spec.json()), + "run_spec": json.loads(run_spec.model_dump_json()), "current_resource": None, }, "force": False, @@ -3379,7 +3380,7 @@ async def test_generates_user_ssh_key(self, session: AsyncSession, client: Async headers=get_auth_headers(user.token), json={ "plan": { - "run_spec": run_spec.dict(), + "run_spec": run_spec.model_dump(), "current_resource": None, }, "force": False, @@ -3434,7 +3435,7 @@ async def test_patches_service_configuration_probes_for_old_clients( headers=headers, json={ "plan": { - "run_spec": run_spec.dict(), + "run_spec": run_spec.model_dump(), "current_resource": None, }, "force": False, @@ -3471,7 +3472,7 @@ async def test_submits_run( session=session, project=project, user=user, project_role=ProjectRole.USER ) submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) - submitted_at_formatted = "2023-01-02T03:04:00+00:00" + submitted_at_formatted = "2023-01-02T03:04:00Z" last_processed_at_formatted = submitted_at_formatted repo = await create_repo(session=session, project_id=project.id) run_dict = get_dev_env_run_dict( @@ -3517,7 +3518,7 @@ async def test_submits_run_docker_true( session=session, project=project, user=user, project_role=ProjectRole.USER ) submitted_at = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) - submitted_at_formatted = "2023-01-02T03:04:00+00:00" + submitted_at_formatted = "2023-01-02T03:04:00Z" last_processed_at_formatted = submitted_at_formatted repo = await create_repo(session=session, project_id=project.id) run_dict = get_dev_env_run_dict( diff --git a/src/tests/_internal/server/routers/test_users.py b/src/tests/_internal/server/routers/test_users.py index 24af6af217..ac7ba24207 100644 --- a/src/tests/_internal/server/routers/test_users.py +++ b/src/tests/_internal/server/routers/test_users.py @@ -60,7 +60,7 @@ async def test_admins_see_all_non_deleted_users( { "id": str(admin.id), "username": admin.name, - "created_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", "global_role": admin.global_role, "email": None, "active": True, @@ -72,7 +72,7 @@ async def test_admins_see_all_non_deleted_users( { "id": str(other_user.id), "username": other_user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": other_user.global_role, "email": None, "active": True, @@ -117,7 +117,7 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: { "id": str(admin.id), "username": admin.name, - "created_at": "2023-01-02T03:06:00+00:00", + "created_at": "2023-01-02T03:06:00Z", "global_role": admin.global_role, "email": None, "active": True, @@ -160,7 +160,7 @@ async def test_paginates_results(self, test_db, session: AsyncSession, client: A { "id": str(admin.id), "username": admin.name, - "created_at": "2023-01-02T03:06:00+00:00", + "created_at": "2023-01-02T03:06:00Z", "global_role": admin.global_role, "email": None, "active": True, @@ -174,7 +174,7 @@ async def test_paginates_results(self, test_db, session: AsyncSession, client: A "/api/users/list", headers=get_auth_headers(admin.token), json={ - "prev_created_at": "2023-01-02T03:06:00+00:00", + "prev_created_at": "2023-01-02T03:06:00Z", "prev_id": str(admin.id), "limit": 1, }, @@ -184,7 +184,7 @@ async def test_paginates_results(self, test_db, session: AsyncSession, client: A { "id": str(user_one.id), "username": user_one.name, - "created_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", "global_role": user_one.global_role, "email": None, "active": True, @@ -228,7 +228,7 @@ async def test_filters_by_name_pattern( { "id": str(matching_user.id), "username": matching_user.name, - "created_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", "global_role": matching_user.global_role, "email": None, "active": True, @@ -262,7 +262,7 @@ async def test_non_admins_see_only_themselves( { "id": str(other_user.id), "username": other_user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": other_user.global_role, "email": None, "active": True, @@ -315,7 +315,7 @@ async def test_returns_logged_in_user( assert response.json() == { "id": str(user.id), "username": user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": user.global_role, "email": None, "creds": {"token": user.token.get_plaintext_or_error()}, @@ -392,7 +392,7 @@ async def test_returns_logged_in_user( assert response.json() == { "id": str(other_user.id), "username": other_user.name, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": other_user.global_role, "email": None, "creds": {"token": "1234"}, @@ -434,7 +434,7 @@ async def test_creates_user(self, test_db, session: AsyncSession, client: AsyncC assert user_data == { "id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e", "username": "test", - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": "user", "email": "test@example.com", "active": True, @@ -472,7 +472,7 @@ async def test_return_400_if_username_taken( assert user_data == { "id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e", "username": "Test", - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": "user", "email": None, "active": True, diff --git a/src/tests/_internal/server/routers/test_volumes.py b/src/tests/_internal/server/routers/test_volumes.py index a2ea344935..e007f928c0 100644 --- a/src/tests/_internal/server/routers/test_volumes.py +++ b/src/tests/_internal/server/routers/test_volumes.py @@ -67,8 +67,8 @@ async def test_lists_volumes_across_projects( "user": user.name, "configuration": json.loads(volume2.configuration), "external": False, - "created_at": "2023-01-02T03:05:00+00:00", - "last_processed_at": "2023-01-02T03:05:00+00:00", + "created_at": "2023-01-02T03:05:00Z", + "last_processed_at": "2023-01-02T03:05:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -86,8 +86,8 @@ async def test_lists_volumes_across_projects( "user": user.name, "configuration": json.loads(volume1.configuration), "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -103,7 +103,7 @@ async def test_lists_volumes_across_projects( "/api/volumes/list", headers=get_auth_headers(user.token), json={ - "prev_created_at": "2023-01-02T03:05:00+00:00", + "prev_created_at": "2023-01-02T03:05:00Z", "prev_id": str(volume2.id), }, ) @@ -116,8 +116,8 @@ async def test_lists_volumes_across_projects( "user": user.name, "configuration": json.loads(volume1.configuration), "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -173,8 +173,8 @@ async def test_non_admin_cannot_see_others_projects( "user": user1.name, "configuration": json.loads(volume1.configuration), "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -221,8 +221,8 @@ async def test_lists_volumes(self, test_db, session: AsyncSession, client: Async "user": user.name, "configuration": json.loads(volume.configuration), "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -269,8 +269,8 @@ async def test_returns_volume(self, test_db, session: AsyncSession, client: Asyn "user": user.name, "configuration": json.loads(volume.configuration), "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, @@ -321,18 +321,18 @@ async def test_creates_volume(self, test_db, session: AsyncSession, client: Asyn response = await client.post( f"/api/project/{project.name}/volumes/create", headers=get_auth_headers(user.token), - json={"configuration": configuration.dict()}, + json={"configuration": configuration.model_dump()}, ) assert response.status_code == 200 assert response.json() == { "id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e", "name": configuration.name, "project_name": project.name, - "configuration": configuration, + "configuration": configuration.model_dump(mode="json"), "user": user.name, "external": False, - "created_at": "2023-01-02T03:04:00+00:00", - "last_processed_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", + "last_processed_at": "2023-01-02T03:04:00Z", "status": "submitted", "status_message": None, "deleted": False, diff --git a/src/tests/_internal/server/services/jobs/configurators/test_service.py b/src/tests/_internal/server/services/jobs/configurators/test_service.py index a8410fcfac..7f39facac5 100644 --- a/src/tests/_internal/server/services/jobs/configurators/test_service.py +++ b/src/tests/_internal/server/services/jobs/configurators/test_service.py @@ -293,7 +293,7 @@ async def test_python_uses_group_python(self): async def test_user_looks_up_group_image(self, monkeypatch: pytest.MonkeyPatch): """When a group sets its own `image`, _user() queries that image's config.""" - image_config = ImageConfig.parse_obj({"User": "nginx", "Entrypoint": None, "Cmd": []}) + image_config = ImageConfig.model_validate({"User": "nginx", "Entrypoint": None, "Cmd": []}) monkeypatch.setattr( "dstack._internal.server.services.jobs.configurators.base._get_image_config", Mock(return_value=image_config), diff --git a/src/tests/_internal/server/services/jobs/configurators/test_task.py b/src/tests/_internal/server/services/jobs/configurators/test_task.py index 6fab966f5e..54b8dd666d 100644 --- a/src/tests/_internal/server/services/jobs/configurators/test_task.py +++ b/src/tests/_internal/server/services/jobs/configurators/test_task.py @@ -47,7 +47,7 @@ async def test_adds_transport_without_credentials(self): job_spec = (await configurator.get_job_specs(replica_num=0))[0] - assert "dstack" not in job_spec.dict() + assert "dstack" not in job_spec.model_dump() assert job_spec.env == { "DSTACK_SERVER_URL": "http+unix://%2Frun%2Fdstack%2Fserver.sock", } diff --git a/src/tests/_internal/server/services/jobs/test_jobs.py b/src/tests/_internal/server/services/jobs/test_jobs.py index 21d69062f1..ce9b931db5 100644 --- a/src/tests/_internal/server/services/jobs/test_jobs.py +++ b/src/tests/_internal/server/services/jobs/test_jobs.py @@ -62,7 +62,7 @@ async def test_get_job_specs_from_run_spec_image_config_calls( profile=Profile(name="default"), ssh_key_pub="user_ssh_key", ) - fake_image_config = ImageConfig.parse_obj({"Entrypoint": ["/bin/bash"]}) + fake_image_config = ImageConfig.model_validate({"Entrypoint": ["/bin/bash"]}) with patch( "dstack._internal.server.services.jobs.configurators.base._get_image_config", return_value=fake_image_config, @@ -83,7 +83,7 @@ async def test_get_image_config_uses_server_default_registry(monkeypatch) -> Non profile=Profile(name="default"), ssh_key_pub="user_ssh_key", ) - fake_image_config = ImageConfig.parse_obj({"Entrypoint": ["/bin/bash"]}) + fake_image_config = ImageConfig.model_validate({"Entrypoint": ["/bin/bash"]}) with patch( "dstack._internal.server.services.jobs.configurators.base._get_image_config", return_value=fake_image_config, diff --git a/src/tests/_internal/server/services/requirements/test_combine.py b/src/tests/_internal/server/services/requirements/test_combine.py index 3680161e83..d9b96da479 100644 --- a/src/tests/_internal/server/services/requirements/test_combine.py +++ b/src/tests/_internal/server/services/requirements/test_combine.py @@ -277,7 +277,7 @@ def test_intersection_with_duplicates(self): assert result == ["a", "a", "c"] -class TestCombineIdleDuration: +class TestCombineOptionalIdleDuration: def test_both_none_returns_none(self): assert _combine_idle_duration_optional(None, None) is None diff --git a/src/tests/_internal/server/services/runner/test_client.py b/src/tests/_internal/server/services/runner/test_client.py index e776cd14b6..efa6b64e32 100644 --- a/src/tests/_internal/server/services/runner/test_client.py +++ b/src/tests/_internal/server/services/runner/test_client.py @@ -84,11 +84,11 @@ def test_adds_default_project_for_server_access(self, adapter: requests_mock.Ada run_spec = get_run_spec( repo_id="repo", configuration=TaskConfiguration(commands=["true"], dstack=True) ) - run = Run.construct(id=uuid.uuid4(), project_name="main", run_spec=run_spec) - job = Job.construct( - job_spec=JobSpec.construct(env={"DSTACK_TOKEN": "token"}), + run = Run.model_construct(id=uuid.uuid4(), project_name="main", run_spec=run_spec) + job = Job.model_construct( + job_spec=JobSpec.model_construct(env={"DSTACK_TOKEN": "token"}), job_submissions=[ - JobSubmission.construct( + JobSubmission.model_construct( id=uuid.uuid4(), submitted_at=datetime.now(timezone.utc), ) @@ -116,11 +116,11 @@ def test_preserves_explicit_project_for_server_access(self, adapter: requests_mo run_spec = get_run_spec( repo_id="repo", configuration=TaskConfiguration(commands=["true"], dstack=True) ) - run = Run.construct(id=uuid.uuid4(), project_name="main", run_spec=run_spec) - job = Job.construct( - job_spec=JobSpec.construct(env={"DSTACK_PROJECT": "other"}), + run = Run.model_construct(id=uuid.uuid4(), project_name="main", run_spec=run_spec) + job = Job.model_construct( + job_spec=JobSpec.model_construct(env={"DSTACK_PROJECT": "other"}), job_submissions=[ - JobSubmission.construct( + JobSubmission.model_construct( id=uuid.uuid4(), submitted_at=datetime.now(timezone.utc), ) diff --git a/src/tests/_internal/server/services/runs/test_runs.py b/src/tests/_internal/server/services/runs/test_runs.py index 0f2f906b75..cc283a6812 100644 --- a/src/tests/_internal/server/services/runs/test_runs.py +++ b/src/tests/_internal/server/services/runs/test_runs.py @@ -4,6 +4,7 @@ from dstack._internal.core.errors import ServerClientError from dstack._internal.core.models.backends.base import BackendType +from dstack._internal.core.models.duration import Duration from dstack._internal.core.models.profiles import Profile, ProfileRetry, RetryEvent from dstack._internal.core.models.runs import JobStatus, JobTerminationReason, RunStatus from dstack._internal.core.models.users import GlobalRole, ProjectRole @@ -43,7 +44,7 @@ async def test_limited_list_materializes_only_latest_and_status_jobs( repo_id=repo.name, profile=Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.NO_CAPACITY]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.NO_CAPACITY]), ), ) run = await create_run( @@ -152,7 +153,7 @@ async def test_limited_list_preserves_status_message_matrix( repo = await create_repo(session=session, project_id=project.id) retry_profile = Profile( name="default", - retry=ProfileRetry(duration=3600, on_events=[RetryEvent.NO_CAPACITY]), + retry=ProfileRetry(duration=Duration(3600), on_events=[RetryEvent.NO_CAPACITY]), ) no_retry_profile = Profile(name="default") diff --git a/src/tests/_internal/server/services/runs/test_spec.py b/src/tests/_internal/server/services/runs/test_spec.py index 093ca768cf..0c62ad7219 100644 --- a/src/tests/_internal/server/services/runs/test_spec.py +++ b/src/tests/_internal/server/services/runs/test_spec.py @@ -56,7 +56,7 @@ def _service_configuration( data["image"] = image if env is not None: data["env"] = env - return ServiceConfiguration.parse_obj(data) + return ServiceConfiguration.model_validate(data) def _run_spec(configuration: ServiceConfiguration, **kwargs): @@ -77,7 +77,7 @@ def _run_spec_with_overrides(configuration: ServiceConfiguration, **overrides) - ) if not run_spec_overrides: return run_spec - return RunSpec.parse_obj({**run_spec.dict(), **run_spec_overrides}) + return RunSpec.model_validate({**run_spec.model_dump(), **run_spec_overrides}) class TestValidateRunSpecRetryDuration: diff --git a/src/tests/_internal/server/services/test_docker.py b/src/tests/_internal/server/services/test_docker.py index 45d2078dc8..14720b08aa 100644 --- a/src/tests/_internal/server/services/test_docker.py +++ b/src/tests/_internal/server/services/test_docker.py @@ -1,7 +1,7 @@ import pytest import dstack._internal.server.settings as server_settings -from dstack._internal.core.models.common import RegistryAuth +from dstack._internal.core.models.common import RegistryAuth, validate_extra_ignore from dstack._internal.server.services.docker import ( ImageConfigObject, ImageManifest, @@ -98,16 +98,16 @@ def sample_image_config_object(): def test_parse_image_manifest(sample_image_manifest): - ImageManifest.__response__.parse_obj(sample_image_manifest) + validate_extra_ignore(ImageManifest, sample_image_manifest) def test_parse_image_config_object(sample_image_config_object): - ImageConfigObject.__response__.parse_obj(sample_image_config_object) + validate_extra_ignore(ImageConfigObject, sample_image_config_object) def test_parse_image_config_object_with_config_null(sample_image_config_object): sample_image_config_object["config"] = None - config_object = ImageConfigObject.__response__.parse_obj(sample_image_config_object) + config_object = validate_extra_ignore(ImageConfigObject, sample_image_config_object) assert config_object.config is not None @@ -121,13 +121,13 @@ def test_parse_image_config_object_with_config_null(sample_image_config_object): ) def test_parse_image_config_object_user_field(sample_image_config_object, value, expected): sample_image_config_object["config"]["User"] = value - config_object = ImageConfigObject.__response__.parse_obj(sample_image_config_object) + config_object = validate_extra_ignore(ImageConfigObject, sample_image_config_object) assert config_object.config.user == expected def test_parse_image_config_object_user_field_missing(sample_image_config_object): del sample_image_config_object["config"]["User"] - config_object = ImageConfigObject.__response__.parse_obj(sample_image_config_object) + config_object = validate_extra_ignore(ImageConfigObject, sample_image_config_object) assert config_object.config.user is None diff --git a/src/tests/_internal/server/services/test_fluentbit_logs.py b/src/tests/_internal/server/services/test_fluentbit_logs.py index 937838e016..f652e0c3aa 100644 --- a/src/tests/_internal/server/services/test_fluentbit_logs.py +++ b/src/tests/_internal/server/services/test_fluentbit_logs.py @@ -68,8 +68,8 @@ def test_init_creates_client(self, mock_httpx_client): def test_write_posts_records(self, mock_httpx_client): writer = HTTPFluentBitWriter(host="localhost", port=8080, tag_prefix="dstack") records = [ - {"message": "Hello", "@timestamp": "2023-10-06T10:00:00+00:00"}, - {"message": "World", "@timestamp": "2023-10-06T10:00:01+00:00"}, + {"message": "Hello", "@timestamp": "2023-10-06T10:00:00Z"}, + {"message": "World", "@timestamp": "2023-10-06T10:00:01Z"}, ] writer.write(tag="test-tag", records=records) @@ -516,7 +516,7 @@ def test_read_returns_logs(self, mock_es_client): "hits": [ { "_source": { - "@timestamp": "2023-10-06T10:01:53.234000+00:00", + "@timestamp": "2023-10-06T10:01:53.234000Z", "message": "Hello", "stream": "test-stream", }, @@ -524,7 +524,7 @@ def test_read_returns_logs(self, mock_es_client): }, { "_source": { - "@timestamp": "2023-10-06T10:01:53.235000+00:00", + "@timestamp": "2023-10-06T10:01:53.235000Z", "message": "World", "stream": "test-stream", }, diff --git a/src/tests/_internal/server/services/test_logs.py b/src/tests/_internal/server/services/test_logs.py index 06bfca7dea..8a8cf3394f 100644 --- a/src/tests/_internal/server/services/test_logs.py +++ b/src/tests/_internal/server/services/test_logs.py @@ -50,8 +50,8 @@ async def test_writes_logs(self, test_db, session: AsyncSession, tmp_path: Path) / "runner.log" ) assert runner_log_path.read_text() == ( - '{"timestamp":"2023-10-06T10:01:53.234000+00:00","log_source":"stdout","message":"Hello"}\n' - '{"timestamp":"2023-10-06T10:01:53.235000+00:00","log_source":"stdout","message":"World"}\n' + '{"timestamp":"2023-10-06T10:01:53.234000Z","log_source":"stdout","message":"Hello"}\n' + '{"timestamp":"2023-10-06T10:01:53.235000Z","log_source":"stdout","message":"World"}\n' ) @pytest.mark.asyncio diff --git a/src/tests/_internal/server/services/test_offers.py b/src/tests/_internal/server/services/test_offers.py index 25ce8021ae..225853cef5 100644 --- a/src/tests/_internal/server/services/test_offers.py +++ b/src/tests/_internal/server/services/test_offers.py @@ -145,7 +145,7 @@ async def test_returns_az_offers(self): aws_offer3 = get_instance_offer_with_availability( backend=BackendType.AWS, availability_zones=["az2", "az3"] ) - expected_aws_offer3 = aws_offer3.copy() + expected_aws_offer3 = aws_offer3.model_copy() expected_aws_offer3.availability_zones = ["az3"] aws_offer4 = get_instance_offer_with_availability( backend=BackendType.AWS, availability_zones=None diff --git a/src/tests/_internal/server/services/test_repos.py b/src/tests/_internal/server/services/test_repos.py index 50e64f84ed..a86be12427 100644 --- a/src/tests/_internal/server/services/test_repos.py +++ b/src/tests/_internal/server/services/test_repos.py @@ -46,7 +46,7 @@ async def _get_repo_creds( return None creds_raw = repo_creds.creds.plaintext assert creds_raw is not None - return RemoteRepoCreds.parse_raw(creds_raw) + return RemoteRepoCreds.model_validate_json(creds_raw) @pytest_asyncio.fixture @@ -76,7 +76,7 @@ async def test_returns_none_if_repo_not_found( project_id=another_project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), + info=repo_info.model_dump(), ) repo = await get_repo( @@ -99,8 +99,8 @@ async def test_returns_repo_with_none_creds_if_include_creds_is_false( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), - creds=legacy_repo_creds.dict(), + info=repo_info.model_dump(), + creds=legacy_repo_creds.model_dump(), ) user_repo_creds = RemoteRepoCreds( clone_url="https://git.example.com/repo.git", @@ -111,7 +111,7 @@ async def test_returns_repo_with_none_creds_if_include_creds_is_false( session=session, repo_id=repo_model.id, user_id=user.id, - creds=user_repo_creds.dict(), + creds=user_repo_creds.model_dump(), ) repo = await get_repo( @@ -134,7 +134,7 @@ async def test_returns_repo_with_none_creds_if_no_user_or_legacy_creds( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), + info=repo_info.model_dump(), creds=None, ) # another user's creds should be ignored @@ -148,7 +148,7 @@ async def test_returns_repo_with_none_creds_if_no_user_or_legacy_creds( session=session, repo_id=repo_model.id, user_id=another_user.id, - creds=another_user_repo_creds.dict(), + creds=another_user_repo_creds.model_dump(), ) repo = await get_repo( @@ -193,8 +193,8 @@ async def test_returns_repo_with_user_creds_if_present( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), - creds=legacy_repo_creds.dict() if legacy_repo_creds else None, + info=repo_info.model_dump(), + creds=legacy_repo_creds.model_dump() if legacy_repo_creds else None, ) user_repo_creds = RemoteRepoCreds( clone_url="https://git.example.com/repo.git", @@ -205,7 +205,7 @@ async def test_returns_repo_with_user_creds_if_present( session=session, repo_id=repo_model.id, user_id=user.id, - creds=user_repo_creds.dict(), + creds=user_repo_creds.model_dump(), ) repo = await get_repo( @@ -232,8 +232,8 @@ async def test_returns_repo_with_legacy_creds_if_user_creds_not_found( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), - creds=legacy_repo_creds.dict(), + info=repo_info.model_dump(), + creds=legacy_repo_creds.model_dump(), ) repo = await get_repo( @@ -285,7 +285,7 @@ async def test_updates_repo_adding_user_creds( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=old_repo_info.dict(), + info=old_repo_info.model_dump(), creds=None, ) @@ -299,7 +299,7 @@ async def test_updates_repo_adding_user_creds( ) assert repo.creds is None - assert RemoteRepoInfo.parse_raw(repo.info) == new_repo_info + assert RemoteRepoInfo.model_validate_json(repo.info) == new_repo_info assert await _get_repo_creds(session, repo.id, user.id) == our_repo_creds async def test_updates_repo_updating_user_creds( @@ -311,7 +311,7 @@ async def test_updates_repo_updating_user_creds( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), + info=repo_info.model_dump(), creds=None, ) old_repo_creds = RemoteRepoCreds( @@ -323,7 +323,7 @@ async def test_updates_repo_updating_user_creds( session=session, repo_id=repo.id, user_id=user.id, - creds=old_repo_creds.dict(), + creds=old_repo_creds.model_dump(), ) new_repo_creds = RemoteRepoCreds( clone_url="ssh://git@git.example.com/repo.git", @@ -356,8 +356,8 @@ async def test_updates_repo_removing_user_creds( project_id=project.id, repo_name=_REPO_ID, repo_type=RepoType.REMOTE, - info=repo_info.dict(), - creds=legacy_repo_creds.dict(), + info=repo_info.model_dump(), + creds=legacy_repo_creds.model_dump(), ) our_repo_creds = RemoteRepoCreds( clone_url="https://git.example.com/repo.git", @@ -368,7 +368,7 @@ async def test_updates_repo_removing_user_creds( session=session, repo_id=repo.id, user_id=user.id, - creds=our_repo_creds.dict(), + creds=our_repo_creds.model_dump(), ) another_user = await _create_user(session, project, name="another-user") another_user_repo_creds = RemoteRepoCreds( @@ -380,7 +380,7 @@ async def test_updates_repo_removing_user_creds( session=session, repo_id=repo.id, user_id=another_user.id, - creds=another_user_repo_creds.dict(), + creds=another_user_repo_creds.model_dump(), ) repo = await init_repo( @@ -394,7 +394,7 @@ async def test_updates_repo_removing_user_creds( # legacy creds stored in the repo are still here assert repo.creds is not None - assert RemoteRepoCreds.parse_raw(repo.creds) == legacy_repo_creds + assert RemoteRepoCreds.model_validate_json(repo.creds) == legacy_repo_creds # our personal creds are deleted assert await _get_repo_creds(session, repo.id, user.id) is None # another user's creds are still here diff --git a/src/tests/_internal/utils/test_json_schema.py b/src/tests/_internal/utils/test_json_schema.py deleted file mode 100644 index f6229e5354..0000000000 --- a/src/tests/_internal/utils/test_json_schema.py +++ /dev/null @@ -1,81 +0,0 @@ -import json - -from dstack._internal.core.models.configurations import DstackConfiguration, ServiceConfiguration -from dstack._internal.core.models.profiles import ProfilesConfig -from dstack._internal.utils.json_schema import add_extra_schema_types - - -class TestAddExtraSchemaTypes: - def test_ref_becomes_any_of(self): - prop = {"$ref": "#/definitions/Foo"} - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop == {"anyOf": [{"$ref": "#/definitions/Foo"}, {"type": "string"}]} - - def test_all_of_keeps_first_ref_only(self): - prop = {"allOf": [{"$ref": "#/definitions/Foo"}]} - add_extra_schema_types(prop, extra_types=[{"type": "integer"}]) - assert prop == {"anyOf": [{"$ref": "#/definitions/Foo"}, {"type": "integer"}]} - - def test_any_of_is_extended_in_place(self): - prop = {"anyOf": [{"type": "integer"}]} - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop == {"anyOf": [{"type": "integer"}, {"type": "string"}]} - - def test_type_is_wrapped(self): - prop = {"type": "integer"} - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop == {"anyOf": [{"type": "integer"}, {"type": "string"}]} - - def test_other_keys_are_preserved(self): - prop = {"title": "Model", "description": "d", "$ref": "#/definitions/Foo"} - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop["title"] == "Model" - assert prop["description"] == "d" - - def test_discriminated_one_of_stays_grouped_with_its_discriminator(self): - # A `Field(discriminator=...)` union renders as `oneOf` plus a sibling `discriminator`. - # The two must move into the same `anyOf` member: a `discriminator` only applies to a - # keyword whose every member carries the tag, so flattening the extra types in beside - # the refs would produce an invalid schema. - prop = { - "title": "Model", - "oneOf": [{"$ref": "#/definitions/Foo"}, {"$ref": "#/definitions/Bar"}], - "discriminator": {"propertyName": "format", "mapping": {}}, - } - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop == { - "title": "Model", - "anyOf": [ - { - "oneOf": [{"$ref": "#/definitions/Foo"}, {"$ref": "#/definitions/Bar"}], - "discriminator": {"propertyName": "format", "mapping": {}}, - }, - {"type": "string"}, - ], - } - - def test_one_of_without_discriminator(self): - prop = {"oneOf": [{"$ref": "#/definitions/Foo"}]} - add_extra_schema_types(prop, extra_types=[{"type": "string"}]) - assert prop == {"anyOf": [{"oneOf": [{"$ref": "#/definitions/Foo"}]}, {"type": "string"}]} - - -class TestSchemaGeneration: - """ - Guards the schemas CI generates and the docs build consumes. Nothing else in the suite - exercises `schema_json()`, so a `schema_extra` hook that cannot handle the shape pydantic - emits for a field fails only in CI. - """ - - def test_dstack_configuration_schema_is_generated(self): - assert json.loads(DstackConfiguration.schema_json())["definitions"] - - def test_profiles_config_schema_is_generated(self): - assert json.loads(ProfilesConfig.schema_json())["definitions"] - - def test_service_model_accepts_both_the_shorthand_and_the_tagged_forms(self): - prop = json.loads(ServiceConfiguration.schema_json())["properties"]["model"] - tagged, shorthand = prop["anyOf"] - assert shorthand == {"type": "string"} - assert tagged["discriminator"]["propertyName"] == "format" - assert tagged["oneOf"] diff --git a/src/tests/api/common.py b/src/tests/api/common.py index c453b6afee..3eda9873ef 100644 --- a/src/tests/api/common.py +++ b/src/tests/api/common.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any, Optional, Union import requests @@ -9,13 +9,13 @@ class RequestRecorder: payload: Any last_path: Optional[str] = None - last_body: Optional[str] = None + last_body: Optional[Union[str, bytes]] = None last_kwargs: dict[str, Any] = field(default_factory=dict) def __call__( self, path: str, - body: Optional[str] = None, + body: Optional[Union[str, bytes]] = None, raise_for_status: bool = True, method: str = "POST", **kwargs, diff --git a/src/tests/api/test_projects.py b/src/tests/api/test_projects.py index 38b93bae5a..6e8bb61248 100644 --- a/src/tests/api/test_projects.py +++ b/src/tests/api/test_projects.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from uuid import UUID +from dstack._internal.utils.common import render_datetime_as_api from dstack.api.server._projects import ProjectsAPIClient from tests.api.common import RequestRecorder @@ -12,14 +13,14 @@ "owner": { "id": "2b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e", "username": "u", - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": "user", "email": None, "active": True, "permissions": {"can_create_projects": True}, "ssh_public_key": None, }, - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "backends": [], "members": [], "is_public": False, @@ -47,7 +48,7 @@ def test_projects_list_serializes_pagination_and_parses_info_list(self): assert payload["include_not_joined"] is True assert payload["return_total_count"] is True assert payload["name_pattern"] == "p" - assert payload["prev_created_at"] == dt.isoformat() + assert payload["prev_created_at"] == render_datetime_as_api(dt) assert payload["prev_id"] == str(pid) assert payload["limit"] == 1 assert payload["ascending"] is True diff --git a/src/tests/api/test_users.py b/src/tests/api/test_users.py index c01703b811..e5c8f9afb6 100644 --- a/src/tests/api/test_users.py +++ b/src/tests/api/test_users.py @@ -3,13 +3,14 @@ from datetime import datetime, timezone from uuid import UUID +from dstack._internal.utils.common import render_datetime_as_api from dstack.api.server._users import UsersAPIClient from tests.api.common import RequestRecorder USER_PAYLOAD = { "id": "11111111-1111-4111-8111-111111111111", "username": "user", - "created_at": "2023-01-02T03:04:00+00:00", + "created_at": "2023-01-02T03:04:00Z", "global_role": "user", "email": None, "active": True, @@ -38,7 +39,7 @@ def test_serializes_pagination_and_parses_info_list(self): assert recorder.last_path == "/api/users/list" assert payload["return_total_count"] is True assert payload["name_pattern"] == "user" - assert payload["prev_created_at"] == dt.isoformat() + assert payload["prev_created_at"] == render_datetime_as_api(dt) assert payload["prev_id"] == str(uid) assert payload["limit"] == 1 assert payload["ascending"] is True diff --git a/src/tests/plugins/test_rest_plugin.py b/src/tests/plugins/test_rest_plugin.py index 7d9e35a51d..d17e58c5dd 100644 --- a/src/tests/plugins/test_rest_plugin.py +++ b/src/tests/plugins/test_rest_plugin.py @@ -6,7 +6,6 @@ import pytest import pytest_asyncio import requests -from pydantic import parse_obj_as from sqlalchemy.ext.asyncio import AsyncSession from dstack._internal.core.errors import ServerClientError, ServerError @@ -44,7 +43,7 @@ async def create_run_spec( run_name=run_name, profile=profile, configuration=ServiceConfiguration( - commands=["echo hello"], port=8000, replicas=parse_obj_as(Range[int], replicas) + commands=["echo hello"], port=8000, replicas=Range[int].model_validate(replicas) ), ) return spec @@ -110,7 +109,7 @@ async def test_on_apply_plugin_service_returns_mutated_spec( mocker.patch.dict(os.environ, {PLUGIN_SERVICE_URI_ENV_VAR_NAME: "http://mock"}) policy = CustomApplyPolicy() mock_response = Mock() - response_dict = {"spec": spec.dict(), "error": None} + response_dict = {"spec": spec.model_dump(), "error": None} if isinstance(spec, (RunSpec, FleetSpec)): response_dict["spec"]["profile"]["tags"] = {"env": "test", "team": "qa"} @@ -197,7 +196,7 @@ async def test_on_apply_plugin_service_error_handling( mocker.patch.dict(os.environ, {PLUGIN_SERVICE_URI_ENV_VAR_NAME: "http://mock"}) policy = CustomApplyPolicy() mock_response = Mock() - response_dict = {"spec": spec.dict(), "error": error} + response_dict = {"spec": spec.model_dump(), "error": error} mock_response.text = json.dumps(response_dict) mock_response.raise_for_status = Mock() mocker.patch("requests.post", return_value=mock_response)