188 lines
8.2 KiB
Python
188 lines
8.2 KiB
Python
"""Optional native declaration checks in an existing FastVideo environment.
|
|
|
|
These tests import real configuration classes without loading models. They are
|
|
separate from the Node-only documentation build and compare paths/types, not
|
|
defaults, deployment bounds, hardware fit, or every runtime consumer.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
from types import UnionType
|
|
from typing import Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
|
|
import pytest
|
|
|
|
from fastvideo.api.schema import GenerationRequest, ServeConfig
|
|
from fastvideo.configs.pipelines.base import PipelineConfig
|
|
from fastvideo.entrypoints.openai.mlx_server import MLXServeConfig
|
|
from fastvideo.fastvideo_args import FastVideoArgs
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
RUNTIME_CONFIGS = {
|
|
"fastvideo-cuda-rest": ServeConfig,
|
|
"fastvideo-cuda-streaming": ServeConfig,
|
|
"fastvideo-mlx-rest": MLXServeConfig,
|
|
}
|
|
JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean", type(None): "null"}
|
|
# Individually verified experimental keys and the native field that receives each one.
|
|
EXPERIMENTAL_DECLARATIONS = {
|
|
"generator.pipeline.experimental.flow_shift": (PipelineConfig, "flow_shift"),
|
|
"generator.pipeline.experimental.video_decode_backend": (FastVideoArgs, "video_decode_backend"),
|
|
"generator.pipeline.experimental.h3_vae_nvfp4_decoder": (FastVideoArgs, "h3_vae_nvfp4_decoder"),
|
|
}
|
|
|
|
|
|
def annotation_at_path(config_type: type, path: str) -> Any:
|
|
current = config_type
|
|
for part in path.split("."):
|
|
branches = get_args(current) if get_origin(current) in (UnionType, Union) else (current,)
|
|
objects = [branch for branch in branches if branch is not type(None)]
|
|
if len(objects) != 1 or not isinstance(objects[0], type):
|
|
raise ValueError(f"{path}: cannot traverse native annotation {current}")
|
|
fields = get_type_hints(objects[0])
|
|
if part not in fields:
|
|
raise ValueError(f"{path}: native {objects[0].__name__} has no field {part}")
|
|
current = fields[part]
|
|
return current
|
|
|
|
|
|
def native_annotation(runtime: str, path: str) -> Any:
|
|
if runtime not in RUNTIME_CONFIGS:
|
|
raise ValueError(f"{runtime}: add a native configuration mapping for this runtime")
|
|
config_type = RUNTIME_CONFIGS[runtime]
|
|
if runtime == "fastvideo-mlx-rest" and path.startswith("default_request."):
|
|
# create_mlx_app normalizes this opaque mapping as GenerationRequest.
|
|
assert annotation_at_path(config_type, "default_request") == dict[str, Any]
|
|
return annotation_at_path(GenerationRequest, path.removeprefix("default_request."))
|
|
if config_type is ServeConfig and path in EXPERIMENTAL_DECLARATIONS:
|
|
# The compatibility adapter forwards experimental keys to pipeline args.
|
|
assert annotation_at_path(config_type, "generator.pipeline.experimental") == dict[str, Any]
|
|
native_type, field = EXPERIMENTAL_DECLARATIONS[path]
|
|
return annotation_at_path(native_type, field)
|
|
return annotation_at_path(config_type, path)
|
|
|
|
|
|
def native_types(annotation: Any) -> set[str]:
|
|
origin = get_origin(annotation)
|
|
if origin in (UnionType, Union):
|
|
return set().union(*(native_types(branch) for branch in get_args(annotation)))
|
|
if origin is Literal:
|
|
return set().union(*(native_types(type(value)) for value in get_args(annotation)))
|
|
if annotation in JSON_TYPES:
|
|
return {JSON_TYPES[annotation]}
|
|
if origin in (list, tuple):
|
|
return {"array"}
|
|
if origin is dict:
|
|
return {"object"}
|
|
raise ValueError(f"No checked JSON type mapping for native annotation {annotation}")
|
|
|
|
|
|
def schema_types(schema: dict) -> set[str]:
|
|
declared = schema.get("type")
|
|
if isinstance(declared, str):
|
|
return {declared}
|
|
if isinstance(declared, list):
|
|
return set(declared)
|
|
branches = schema.get("anyOf", schema.get("oneOf"))
|
|
if isinstance(branches, list) and branches:
|
|
return set().union(*(schema_types(branch) for branch in branches))
|
|
raise ValueError("Authored field schema must declare its types")
|
|
|
|
|
|
def check_field(schema: dict, annotation: Any, context: str) -> None:
|
|
allowed = native_types(annotation)
|
|
if "number" in allowed:
|
|
allowed.add("integer")
|
|
declared = schema_types(schema)
|
|
if not declared <= allowed:
|
|
raise ValueError(f"{context}: authored types {sorted(declared)} exceed native types {sorted(allowed)}")
|
|
|
|
|
|
def check_native_field(runtime: str, path: str, schema: dict, catalog_id: str | None = None) -> None:
|
|
context = f"{catalog_id}: {runtime}: {path}" if catalog_id else f"{runtime}: {path}"
|
|
try:
|
|
annotation = native_annotation(runtime, path)
|
|
except ValueError as error:
|
|
raise ValueError(f"{context}: {error}") from error
|
|
check_field(schema, annotation, context)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def authored_contract():
|
|
source = """
|
|
import { readFileSync } from 'node:fs';
|
|
import { parse } from 'yaml';
|
|
import { buildCatalogs, mergeOptions } from './js/build-cookbook-config.mjs';
|
|
const definitions = parse(readFileSync('./cookbook/options.yaml', 'utf8'), { merge: true });
|
|
const runtimes = Object.fromEntries(Object.entries(definitions.runtimes).map(([id, runtime]) =>
|
|
[id, mergeOptions(definitions.options, runtime.options)]));
|
|
process.stdout.write(JSON.stringify({ runtimes, catalogs: buildCatalogs() }));
|
|
"""
|
|
result = subprocess.run(["node", "--input-type=module"],
|
|
input=source,
|
|
text=True,
|
|
capture_output=True,
|
|
cwd=ROOT / "docs",
|
|
check=False)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_all_authored_runtime_fields_match_native_declarations(authored_contract):
|
|
for runtime, options in authored_contract["runtimes"].items():
|
|
for path, schema in options.items():
|
|
check_native_field(runtime, path, schema)
|
|
|
|
|
|
def test_all_discovered_deployment_controls_match_native_declarations(authored_contract):
|
|
for catalog in authored_contract["catalogs"]:
|
|
runtime = catalog["runtime"]["id"]
|
|
for control in catalog["controls"]:
|
|
check_native_field(runtime, control["path"], control["schema"], catalog["id"])
|
|
|
|
|
|
@dataclass
|
|
class SampleConfig:
|
|
port: int = 8000
|
|
name: str | None = None
|
|
|
|
|
|
def test_renamed_native_path_reports_field_context():
|
|
with pytest.raises(ValueError, match="missing_port.*SampleConfig.*missing_port"):
|
|
annotation_at_path(SampleConfig, "missing_port")
|
|
|
|
|
|
def test_native_type_drift_reports_runtime_and_field():
|
|
with pytest.raises(ValueError, match="sample-runtime: server.port.*string.*integer"):
|
|
check_field({"type": "string"}, annotation_at_path(SampleConfig, "port"), "sample-runtime: server.port")
|
|
|
|
|
|
def test_renamed_native_path_reports_runtime_and_catalog_context():
|
|
with pytest.raises(ValueError, match="sample/rest: fastvideo-cuda-rest: server.missing_port.*has no field"):
|
|
check_native_field("fastvideo-cuda-rest", "server.missing_port", {"type": "integer"}, "sample/rest")
|
|
|
|
|
|
@pytest.mark.parametrize("schema,annotation", [
|
|
({"type": "string"}, str | None),
|
|
({"anyOf": [{"type": "string"}, {"type": "null"}]}, str | None),
|
|
({"type": "integer", "minimum": 1}, float),
|
|
({"type": "string", "enum": ["small"]}, Literal["small", "large"]),
|
|
])
|
|
def test_recipe_type_narrowing_is_allowed(schema, annotation):
|
|
check_field(schema, annotation, "sample-runtime: field")
|
|
|
|
|
|
def test_recipe_cannot_widen_native_nullability_or_integer_type():
|
|
for schema, annotation in [({"type": ["integer", "null"]}, int), ({"type": "number"}, int)]:
|
|
with pytest.raises(ValueError, match="sample-runtime: field.*exceed native"):
|
|
check_field(schema, annotation, "sample-runtime: field")
|
|
|
|
|
|
def test_opaque_native_fields_require_an_explicit_mapping():
|
|
with pytest.raises(ValueError, match="cannot traverse native annotation"):
|
|
native_annotation("fastvideo-cuda-rest", "generator.pipeline.experimental.new_option")
|
|
with pytest.raises(ValueError, match="No checked JSON type mapping"):
|
|
check_field({"type": "string"}, Any, "sample-runtime: field")
|