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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"argcomplete>=3.5.0",
"ignore-python>=0.2.0",
"orjson",
"apscheduler<4",
]

Expand Down Expand Up @@ -89,6 +87,9 @@ ignore-case = true

[tool.uv.sources]
dstack-plugin-server = { path = "examples/plugins/example_plugin_server", editable = true }
# TODO: pin back to a released version once the pydantic v2 gpuhunt release is out.
# https://github.com/dstackai/gpuhunt/pull/247
gpuhunt = { git = "https://github.com/dstackai/gpuhunt", branch = "pr_pydantic_v2" }

[tool.ruff]
target-version = "py310"
Expand All @@ -115,7 +116,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",
]
Expand All @@ -140,9 +140,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]
Expand Down
36 changes: 26 additions & 10 deletions scripts/add_backend.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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__":
Expand Down
5 changes: 4 additions & 1 deletion scripts/docs/gen_openapi_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
TAG_LIST_END = "<!-- END GENERATED HTTP API TAGS -->"
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")
Expand Down
153 changes: 93 additions & 60 deletions scripts/docs/gen_schema_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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]``.
Expand Down Expand Up @@ -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"

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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")
Expand Down
3 changes: 1 addition & 2 deletions src/dstack/_internal/cli/commands/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Loading
Loading