Skip to content

Commit d10edfd

Browse files
authored
Migrate to Pydantic v2 (#4077)
* Migrate to pydantic v2 * Simplify XOrShorthand types * Fix duration fields annotated types * Switch to new model methods * Fix gen_schema_reference.py * Fix merged_profile comments * Fixes * Minor fixes * Fix backend template * Fix after master merge * Allow Python 3.14 * Allow python: 3.14 in run configurations * Test on python 3.14 * Update gpuhunt
1 parent cd8e145 commit d10edfd

293 files changed

Lines changed: 5797 additions & 3987 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-artifacts.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ jobs:
6464
strategy:
6565
matrix:
6666
os: [macos-latest, ubuntu-latest, windows-latest]
67-
python-version: ["3.10", "3.11", "3.12", "3.13"]
67+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
6868
steps:
6969
- uses: actions/checkout@v4
7070
- name: Set up Python ${{ matrix.python-version }}
@@ -245,8 +245,8 @@ jobs:
245245
- name: Generate json schema
246246
run: |
247247
mkdir /tmp/json-schemas
248-
uv run python -c "from dstack._internal.core.models.configurations import DstackConfiguration; print(DstackConfiguration.schema_json())" > /tmp/json-schemas/configuration.json
249-
uv run python -c "from dstack._internal.core.models.profiles import ProfilesConfig; print(ProfilesConfig.schema_json())" > /tmp/json-schemas/profiles.json
248+
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
249+
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
250250
- uses: actions/upload-artifact@v4
251251
with:
252252
name: json-schemas

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,14 @@ dependencies = [
2727
"rich-argparse",
2828
"tqdm",
2929
"questionary>=2.0.1",
30-
"pydantic>=1.10.10,<2.0.0",
31-
"pydantic-duality>=1.2.4",
30+
"pydantic>=2.12",
3231
"websocket-client",
3332
"python-multipart>=0.0.16",
3433
"filelock",
3534
"psutil",
36-
"gpuhunt==0.1.27",
35+
"gpuhunt==0.1.29",
3736
"argcomplete>=3.5.0",
3837
"ignore-python>=0.2.0",
39-
"orjson",
4038
"apscheduler<4",
4139
]
4240

@@ -115,7 +113,6 @@ include = [
115113
"src/dstack/_internal/core/backends/runpod",
116114
"src/dstack/_internal/core/backends/slurm",
117115
"src/dstack/_internal/cli/services/configurators",
118-
"src/dstack/_internal/cli/services/endpoints",
119116
"src/dstack/_internal/cli/commands",
120117
"src/tests/_internal/server/background/pipeline_tasks",
121118
]
@@ -140,9 +137,12 @@ env = [
140137
"DSTACK_SSHPROXY_API_TOKEN=test-token",
141138
]
142139
filterwarnings = [
140+
# Fail on any use of a pydantic v1 shim (`.dict()`, `parse_obj_as`, class-based `Config`, ...)
141+
# so they cannot creep back after the v2 migration.
142+
"error::pydantic.PydanticDeprecatedSince20",
143143
# testcontainers modules use deprecated decorators – nothing we can do:
144144
# https://github.com/testcontainers/testcontainers-python/issues/874
145-
"ignore:^The @wait_container_is_ready decorator:DeprecationWarning"
145+
"ignore:^The @wait_container_is_ready decorator:DeprecationWarning",
146146
]
147147

148148
[dependency-groups]

scripts/add_backend.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import argparse
22
from pathlib import Path
3+
from typing import Optional
34

45
import jinja2
56

7+
TEMPLATE_DIR_PATH = Path(__file__).parent.parent.joinpath(
8+
"src/dstack/_internal/core/backends/template"
9+
)
10+
BACKENDS_DIR_PATH = Path(__file__).parent.parent.joinpath("src/dstack/_internal/core/backends")
11+
TEMPLATE_FILENAMES = ["backend.py", "compute.py", "configurator.py", "models.py"]
12+
613

714
def main():
815
parser = argparse.ArgumentParser(
@@ -21,25 +28,34 @@ def main():
2128
generate_backend_code(args.name)
2229

2330

24-
def generate_backend_code(backend_name: str):
25-
template_dir_path = Path(__file__).parent.parent.joinpath(
26-
"src/dstack/_internal/core/backends/template"
27-
)
31+
def generate_backend_code(backend_name: str, backends_dir_path: Optional[Path] = None) -> Path:
32+
"""
33+
Renders the scaffold templates for a new backend.
34+
35+
Args:
36+
backend_name: The backend name in CamelCase, e.g. `VastAI`.
37+
backends_dir_path: Where to create the backend package. Defaults to the real backends
38+
directory; tests pass a temporary one.
39+
40+
Returns:
41+
The path of the generated backend package.
42+
"""
2843
env = jinja2.Environment(
2944
loader=jinja2.FileSystemLoader(
30-
searchpath=template_dir_path,
45+
searchpath=TEMPLATE_DIR_PATH,
3146
),
3247
keep_trailing_newline=True,
3348
)
34-
backend_dir_path = Path(__file__).parent.parent.joinpath(
35-
f"src/dstack/_internal/core/backends/{backend_name.lower()}"
36-
)
37-
backend_dir_path.mkdir(exist_ok=True)
38-
for filename in ["backend.py", "compute.py", "configurator.py", "models.py"]:
49+
if backends_dir_path is None:
50+
backends_dir_path = BACKENDS_DIR_PATH
51+
backend_dir_path = backends_dir_path.joinpath(backend_name.lower())
52+
backend_dir_path.mkdir(parents=True, exist_ok=True)
53+
for filename in TEMPLATE_FILENAMES:
3954
template = env.get_template(f"{filename}.jinja")
4055
with open(backend_dir_path.joinpath(filename), "w+") as f:
4156
f.write(template.render({"backend_name": backend_name}))
4257
backend_dir_path.joinpath("__init__.py").write_text("")
58+
return backend_dir_path
4359

4460

4561
if __name__ == "__main__":

scripts/docs/gen_openapi_reference.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
TAG_LIST_END = "<!-- END GENERATED HTTP API TAGS -->"
2525
HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
2626
UNTAGGED_TAG = "default"
27-
OPENAPI_VERSION = "3.0.3"
27+
# Must stay 3.1.x: pydantic v2 generates JSON Schema draft 2020-12, so the spec contains
28+
# constructs 3.0 has no equivalent for (`{"type": "null"}` for an optional field, `const`).
29+
# Declaring 3.0.3 over those produces a spec no validator accepts. swagger-ui renders 3.1.
30+
OPENAPI_VERSION = "3.1.0"
2831

2932
if os.environ.get(disable_env):
3033
logger.warning("OpenAPI reference generation is disabled")

scripts/docs/gen_schema_reference.py

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
import mkdocs_gen_files
1515
import yaml
16-
from pydantic.main import BaseModel
16+
from pydantic import BaseModel, RootModel
17+
from pydantic_core import PydanticUndefined
1718
from typing_extensions import Annotated, Any, Dict, Literal, Type, Union, get_args, get_origin
1819

1920
from dstack._internal.core.models.resources import Range
@@ -25,20 +26,65 @@
2526
logger.info("Generating schema reference...")
2627

2728

28-
def _is_linkable_type(annotation: Any) -> bool:
29-
"""Check if a type annotation contains a BaseModel subclass (excluding Range)."""
29+
def _unwrap_optional(annotation: Any) -> Any:
30+
"""The non-`None` member of an `Optional[...]`, or the annotation unchanged."""
31+
if get_origin(annotation) is Union:
32+
args = [a for a in get_args(annotation) if a is not type(None)]
33+
if len(args) == 1:
34+
return args[0]
35+
return annotation
36+
37+
38+
def _linkable_model(annotation: Any) -> Optional[type]:
39+
"""
40+
The `BaseModel` subclass a field links to in the reference, if any.
41+
42+
pydantic v2 strips `Annotated` off `FieldInfo.annotation`, so the shape this used to unwrap by
43+
hand (`Annotated[Optional[SSHParams], Field(...)]`) now arrives as plain `Optional[SSHParams]`.
44+
Recursing over the annotation covers both, and also catches a bare model field, which the old
45+
`get_args(...)[0]` approach silently missed.
46+
"""
3047
origin = get_origin(annotation)
48+
# The container cases come first: `get_origin(Annotated[X, ...])` is `Annotated`, which is
49+
# itself a class, so testing `inspect.isclass` first would stop before ever unwrapping it.
50+
if origin in (Annotated, Union, list):
51+
for arg in get_args(annotation):
52+
if arg is type(None):
53+
continue
54+
found = _linkable_model(arg)
55+
if found is not None:
56+
return found
57+
return None
3158
type_ = origin if origin is not None else annotation
32-
if inspect.isclass(type_):
33-
return issubclass(type_, BaseModel) and not issubclass(type_, Range)
34-
if origin is Annotated:
35-
return _is_linkable_type(get_args(annotation)[0])
36-
if origin is Union:
37-
return any(_is_linkable_type(arg) for arg in get_args(annotation))
38-
if origin is list:
39-
args = get_args(annotation)
40-
return bool(args) and _is_linkable_type(args[0])
41-
return False
59+
if inspect.isclass(type_) and issubclass(type_, BaseModel) and not issubclass(type_, Range):
60+
return type_
61+
return None
62+
63+
64+
# Scalar JSON Schema types, mapped to how the docs spell them. Deliberately an allowlist rather
65+
# than a full mapping: `array` and `object` would only restate what the annotation already renders
66+
# more precisely (`list[str]` gaining a bare `list`, `dict` gaining `object`), and merging anything
67+
# into a bracketed type corrupts it, since `_enrich_type_from_schema` splits the rendered type on
68+
# `" | "` — which `list["no-capacity" | "interruption"]` contains.
69+
_ENRICHABLE = {"string": "str", "integer": "int", "boolean": "bool", "number": "float"}
70+
71+
72+
def _shorthand_primitives(model: Type) -> list:
73+
"""
74+
The primitive types a model accepts in place of its object form, e.g. `8` or `arm:8` for
75+
`CPUSpec`. Taken from the model's own validation JSON Schema, which is the same declaration
76+
that produces the published `configuration.json`.
77+
"""
78+
try:
79+
schema = model.model_json_schema(mode="validation")
80+
except Exception:
81+
return []
82+
found = {
83+
_ENRICHABLE[entry["type"]]
84+
for entry in schema.get("anyOf", [])
85+
if entry.get("type") in _ENRICHABLE
86+
}
87+
return sorted(found, key=_type_sort_key)
4288

4389

4490
def _type_sort_key(t: str) -> tuple:
@@ -57,7 +103,7 @@ def _type_sort_key(t: str) -> tuple:
57103
return (5, t)
58104

59105

60-
def get_friendly_type(annotation: Type) -> str:
106+
def get_friendly_type(annotation: Any) -> str:
61107
"""Get a user-friendly type string for documentation.
62108
63109
Produces types like: ``int | str``, ``"vscode" | "cursor"``, ``list[object]``.
@@ -112,10 +158,11 @@ def get_friendly_type(annotation: Type) -> str:
112158

113159
# Range — depends on inner type parameter
114160
if issubclass(annotation, Range):
115-
min_field = annotation.__fields__.get("min")
116-
if min_field and inspect.isclass(min_field.type_):
161+
min_field = annotation.model_fields.get("min")
162+
inner = _unwrap_optional(min_field.annotation) if min_field else None
163+
if inspect.isclass(inner):
117164
# Range[Memory] → str, Range[int] → int | str
118-
if issubclass(min_field.type_, float):
165+
if issubclass(inner, float):
119166
return "str"
120167
return "int | str"
121168

@@ -127,13 +174,16 @@ def get_friendly_type(annotation: Type) -> str:
127174

128175
# BaseModel subclass (not Range)
129176
if issubclass(annotation, BaseModel) and not issubclass(annotation, Range):
130-
# Root models (with __root__ field) — resolve from the root type
131-
if "__root__" in annotation.__fields__:
132-
return get_friendly_type(annotation.__fields__["__root__"].annotation)
133-
# Models with custom __get_validators__ accept primitive input (int, str)
134-
# in addition to the full object form (e.g., GPUSpec, CPUSpec, DiskSpec)
135-
if "__get_validators__" in annotation.__dict__:
136-
return "int | str | object"
177+
# Root models — resolve from the root type
178+
if issubclass(annotation, RootModel):
179+
return get_friendly_type(annotation.model_fields["root"].annotation)
180+
# Models that define their own core schema also accept a shorthand. Read which
181+
# primitives from the model's own JSON Schema rather than assuming `int | str`:
182+
# `CPUSpec` takes both, but `FilePathMapping` and `RepoSpec` take only a string.
183+
if "__get_pydantic_core_schema__" in annotation.__dict__:
184+
shorthand = _shorthand_primitives(annotation)
185+
if shorthand:
186+
return " | ".join([*shorthand, "object"])
137187
return "object"
138188

139189
# ComputeCapability (tuple subclass that parses "7.5" strings)
@@ -163,33 +213,24 @@ def get_friendly_type(annotation: Type) -> str:
163213
return str(annotation)
164214

165215

166-
_JSON_SCHEMA_TYPE_MAP = {
167-
"string": "str",
168-
"integer": "int",
169-
"number": "float",
170-
"boolean": "bool",
171-
"array": "list",
172-
"object": "object",
173-
}
174-
175-
176216
def _enrich_type_from_schema(friendly_type: str, prop_schema: Dict[str, Any]) -> str:
177217
"""Enrich the friendly type with extra accepted types from the JSON schema.
178218
179-
Models may define ``schema_extra`` that adds ``anyOf`` entries for fields
180-
that accept alternative input types (e.g., duration fields typed as ``int``
181-
but also accepting ``str`` like ``"5m"``).
219+
A field's annotation is its *post-validation* type, so it does not show what a before-validator
220+
also accepts — a duration typed ``int`` takes ``"5m"``, ``false`` and ``"off"`` as well. Those
221+
come from the type's ``json_schema_input_type``, i.e. the same declaration that produces the
222+
published schema.
182223
"""
183224
any_of = prop_schema.get("anyOf")
184225
if not any_of:
185226
return friendly_type
186-
# Only consider string/integer — the most common alternative input types.
187-
# Skip boolean (typically a backward-compat artifact) and object/array.
188-
_ENRICHABLE = {"string": "str", "integer": "int"}
189227
schema_types = set()
190228
for entry in any_of:
191-
# Skip entries with enum constraints — those are already captured as literal values
192-
if "enum" in entry:
229+
# A single accepted value (`Literal["off"]`) is more useful spelled out than as `str`.
230+
# Duplicates are removed below, so an annotation that already shows it is unaffected.
231+
literals = [entry["const"]] if "const" in entry else entry.get("enum", [])
232+
if literals:
233+
schema_types.update(f'"{v}"' for v in literals if isinstance(v, str))
193234
continue
194235
mapped = _ENRICHABLE.get(entry.get("type", ""))
195236
if mapped:
@@ -200,9 +241,6 @@ def _enrich_type_from_schema(friendly_type: str, prop_schema: Dict[str, Any]) ->
200241
if not new_parts:
201242
return friendly_type
202243
all_parts = list(set(current_parts) | new_parts)
203-
# If str is now present, single-value literals are redundant
204-
if "str" in all_parts:
205-
all_parts = [p for p in all_parts if not p.startswith('"') or p in all_parts]
206244
all_parts.sort(key=_type_sort_key)
207245
return " | ".join(all_parts)
208246

@@ -228,15 +266,17 @@ def generate_schema_reference(
228266
"",
229267
]
230268
)
231-
# Get JSON schema to detect extra accepted types from schema_extra
269+
# The schema says what a field *accepts*, which is wider than its annotation wherever a
270+
# before-validator coerces. `mode="validation"` is pydantic's default, but state it: the
271+
# serialization schema carries the narrow type and would defeat the whole point.
232272
try:
233-
schema_props = cls.schema().get("properties", {})
273+
schema_props = cls.model_json_schema(mode="validation").get("properties", {})
234274
except Exception:
235275
schema_props = {}
236-
for name, field in cls.__fields__.items():
276+
for name, field in cls.model_fields.items():
237277
default = field.default
238278
default_repr: Optional[str]
239-
if default is None:
279+
if default is None or default is PydanticUndefined:
240280
default_repr = None
241281
elif isinstance(default, (list, tuple, dict)) and len(default) == 0:
242282
default_repr = None
@@ -252,24 +292,17 @@ def generate_schema_reference(
252292
friendly_type = _enrich_type_from_schema(friendly_type, schema_props.get(name, {}))
253293
values = dict(
254294
name=name,
255-
description=field.field_info.description,
295+
description=field.description,
256296
type=friendly_type,
257297
default=default_repr,
258-
required=field.required,
298+
required=field.is_required(),
259299
)
260300
# TODO: If the field doesn't have description (e.g. BaseConfiguration.type), we could fallback to docstring
261301
if values["description"]:
262302
if overrides and name in overrides:
263303
values.update(overrides[name])
264-
field_type = next(iter(get_args(field.annotation)), None)
265-
# TODO: This is a dirty workaround
266-
if field_type:
267-
if field.annotation.__name__ == "Annotated":
268-
if field_type.__name__ in ["Optional", "List", "list", "Union"]:
269-
field_type = get_args(field_type)[0]
270-
base_model = _is_linkable_type(field_type)
271-
else:
272-
base_model = False
304+
field_type = _linkable_model(field.annotation)
305+
base_model = field_type is not None
273306
_defaults = (
274307
f"Defaults to `{values['default']}`."
275308
if not base_model and values.get("default")

src/dstack/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +0,0 @@
1-
import sys
2-
3-
if sys.version_info >= (3, 14):
4-
raise ImportError("dstack does not support Python 3.14 or later. Please use Python 3.10–3.13.")

src/dstack/_internal/cli/commands/fleet.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from dstack._internal.cli.utils.fleet import get_fleets_table, print_fleets_table
1616
from dstack._internal.core.errors import CLIError, ResourceNotExistsError
1717
from dstack._internal.core.models.common import EntityReference
18-
from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent
1918

2019

2120
class FleetCommand(APIBaseCommand):
@@ -176,4 +175,4 @@ def _get(self, args: argparse.Namespace):
176175
console.print(f"Fleet [code]{args.name or args.id}[/] not found")
177176
exit(1)
178177

179-
print(pydantic_orjson_dumps_with_indent(fleet.dict(), default=None))
178+
print(fleet.model_dump_json(indent=2))

0 commit comments

Comments
 (0)