Skip to content
  •  
  •  
  •  
Empty file.
651 changes: 651 additions & 0 deletions src/tests/_internal/pydantic_compat/backend_factories.py

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions src/tests/_internal/pydantic_compat/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
Fixture comparison for the pydantic v1 → v2 migration.

Fixtures are generated under pydantic v1 and committed. The same tests then run under v2, so a
mismatch means the two versions disagree about the wire format. Regenerating a fixture IS the act
of accepting a wire change — every accepted diff shows up in review as a fixture diff.

Layout is `fixtures/<direction>/<surface>/<model>[.<variant>].<role>`:

- direction: `serialization` or `parsing`
- surface: the boundary, e.g. `db`, `backend_config`, `api_request`, `config`. The registries in
`test_serialization.py` are the list
- variant: omitted while a model has only one case; added to *every* case for that model as soon
as a second one exists, so `volume.input.yml` becomes `volume.size.input.yml` and
`volume.kubernetes.input.yml` together
- role: `input` for hand-written parse inputs, `values` / `types` for generated expectations, and
a bare `.json` for serialization fixtures, which need no input

`fixtures/schema/` is the exception: the published JSON Schemas have no surface or role level, so
it is `fixtures/schema/<name>.json`. See `test_schema.py`.
"""

import difflib
import json
from pathlib import Path
from typing import Any, Union

import pytest
from pydantic import BaseModel

FIXTURES_DIR = Path(__file__).parent / "fixtures"

# Types JSON represents faithfully. Anything else — a `Duration` (int subclass), a `Memory`
# (float subclass), an enum, or a model standing in for a union arm — renders identically to its
# base type, so the value dump cannot show which one parsing produced.
_JSON_NATIVE = (str, int, float, bool, type(None))

_REGEN_HINT = (
"Fix the regression, or if the change is intended, accept it with"
" `pytest src/tests/_internal/pydantic_compat --regen-fixtures`."
)


def canonicalize(payload: Union[bytes, str]) -> str:
"""
Re-render a JSON payload so that only meaningful differences survive.

`sort_keys` drops key-order noise, which the v2 serializer is free to change.

Comparing the re-rendered *text* rather than the parsed objects is deliberate: `==` on parsed
JSON would miss the drift this suite exists to catch, because Python compares `16 == 16.0` and
`True == 1` as equal. `Memory` is a `float` subclass and `Duration` an `int` subclass, so an
int/float representation change is one of the likeliest v2 diffs — and `json.dumps` renders
`16` and `16.0` distinctly where `==` cannot tell them apart.
"""
return json.dumps(json.loads(payload), indent=2, sort_keys=True) + "\n"


def assert_matches_fixture(kind: str, name: str, payload: Union[bytes, str], regen: bool) -> None:
"""Compare a JSON payload against `fixtures/<kind>/<name>.json`."""
path = FIXTURES_DIR / kind / f"{name}.json"
actual = canonicalize(payload)

if regen:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(actual)
return

if not path.exists():
pytest.fail(f"no fixture at {path.relative_to(FIXTURES_DIR.parent)}. {_REGEN_HINT}")

expected = path.read_text()
if actual != expected:
diff = "".join(
difflib.unified_diff(
expected.splitlines(keepends=True),
actual.splitlines(keepends=True),
fromfile=f"{kind}/{name}.json (committed)",
tofile=f"{kind}/{name}.json (actual)",
)
)
pytest.fail(f"{kind}/{name} changed:\n{diff}\n{_REGEN_HINT}")


def type_map(value: Any, path: str = "", out: Union[dict, None] = None) -> dict:
"""
Map JSON pointer -> the concrete class parsing produced, wherever JSON erases it.

`Duration(7200)` and `7200` serialize identically, as do `Memory(16.0)` and `16.0`, and
`PythonVersion.PY310` and `"3.10"`. Losing the subclass is therefore invisible in the value
dump while still being a real regression — `Memory.__str__` is `"16GB"`, and that string
reaches the CLI. Models are recorded as well as recursed into, so a union that resolves to a
different arm shows up here even when both arms happen to serialize the same.
"""
out = {} if out is None else out
if isinstance(value, BaseModel):
out[path or "/"] = class_name(value)
for name, attr in value.__dict__.items():
type_map(attr, f"{path}/{name}", out)
elif isinstance(value, dict):
for key, attr in value.items():
type_map(attr, f"{path}/{key}", out)
elif isinstance(value, (list, tuple)):
for i, attr in enumerate(value):
type_map(attr, f"{path}/{i}", out)
elif type(value) not in _JSON_NATIVE:
out[path] = class_name(value)
return out


def class_name(value: Any) -> str:
"""
The model's name with pydantic-duality's generated suffix removed.

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`.
"""
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
47 changes: 47 additions & 0 deletions src/tests/_internal/pydantic_compat/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
The only place in this package allowed to branch on pydantic version.

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`.

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
that may well want real strict mode later should not have the term already spent on something
else.
"""

from typing import Any

import pydantic
from pydantic import BaseModel

PYDANTIC_V1 = pydantic.VERSION.startswith("1.")


def parse_forbid_extra(model: Any, data: Any) -> BaseModel:
"""
Parse with `extra="forbid"` — the way the server validates a request body or a user's YAML.

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)


def parse_ignore_extra(model: Any, data: Any) -> BaseModel:
"""
Parse with `extra="ignore"` — the way a stored blob or a peer's response is read.

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")
28 changes: 28 additions & 0 deletions src/tests/_internal/pydantic_compat/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pytest


def pytest_addoption(parser):
parser.addoption(
"--regen-fixtures",
action="store_true",
default=False,
help="Rewrite the pydantic_compat fixtures instead of asserting against them",
)


def pytest_collection_modifyitems(config, items):
"""
Mark everything in this package `pydantic_compat` so `--pydantic-compat` gates it.

Applied here rather than as a `pytestmark` in each module so a module added later cannot
silently escape the gate and start failing regular CI.
"""
package_dir = __file__.rsplit("/", 1)[0]
for item in items:
if str(item.path).startswith(package_dir):
item.add_marker(pytest.mark.pydantic_compat)


@pytest.fixture
def regen(request) -> bool:
return request.config.getoption("--regen-fixtures")
Loading
Loading