9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
257 lines
8.4 KiB
Python
257 lines
8.4 KiB
Python
"""Tests for the shared _spec.py module."""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from pydantic_ai._spec import NamedSpec, build_registry, build_schema_types, load_from_registry
|
|
|
|
|
|
class TestNamedSpec:
|
|
"""Test NamedSpec parsing of all three short forms."""
|
|
|
|
def test_bare_name(self):
|
|
spec = NamedSpec.model_validate('MyClass')
|
|
assert spec.name == 'MyClass'
|
|
assert spec.arguments is None
|
|
assert spec.args == ()
|
|
assert spec.kwargs == {}
|
|
|
|
def test_single_arg(self):
|
|
spec = NamedSpec.model_validate({'MyClass': 'hello'})
|
|
assert spec.name == 'MyClass'
|
|
assert spec.arguments == ('hello',)
|
|
assert spec.args == ('hello',)
|
|
assert spec.kwargs == {}
|
|
|
|
def test_kwargs(self):
|
|
spec = NamedSpec.model_validate({'MyClass': {'a': 1, 'b': 2}})
|
|
assert spec.name == 'MyClass'
|
|
assert spec.arguments == {'a': 1, 'b': 2}
|
|
assert spec.args == ()
|
|
assert spec.kwargs == {'a': 1, 'b': 2}
|
|
|
|
def test_single_non_string_arg(self):
|
|
spec = NamedSpec.model_validate({'MyClass': 42})
|
|
assert spec.name == 'MyClass'
|
|
assert spec.arguments == (42,)
|
|
|
|
def test_single_list_arg(self):
|
|
spec = NamedSpec.model_validate({'MyClass': [1, 2, 3]})
|
|
assert spec.name == 'MyClass'
|
|
assert spec.arguments == ([1, 2, 3],)
|
|
|
|
def test_long_form(self):
|
|
spec = NamedSpec(name='MyClass', arguments={'x': 1, 'y': 2})
|
|
assert spec.name == 'MyClass'
|
|
assert spec.kwargs == {'x': 1, 'y': 2}
|
|
|
|
def test_multiple_keys_error(self):
|
|
with pytest.raises(Exception):
|
|
NamedSpec.model_validate({'A': 1, 'B': 2})
|
|
|
|
def test_serialize_short_form_no_args(self):
|
|
spec = NamedSpec(name='MyClass', arguments=None)
|
|
result = spec.model_dump(context={'use_short_form': True})
|
|
assert result == 'MyClass'
|
|
|
|
def test_serialize_short_form_single_arg(self):
|
|
spec = NamedSpec(name='MyClass', arguments=('hello',))
|
|
result = spec.model_dump(context={'use_short_form': True})
|
|
assert result == {'MyClass': 'hello'}
|
|
|
|
def test_serialize_short_form_kwargs(self):
|
|
spec = NamedSpec(name='MyClass', arguments={'a': 1})
|
|
result = spec.model_dump(context={'use_short_form': True})
|
|
assert result == {'MyClass': {'a': 1}}
|
|
|
|
|
|
class _Base:
|
|
@classmethod
|
|
def get_name(cls) -> str | None:
|
|
return cls.__name__
|
|
|
|
|
|
class Foo(_Base):
|
|
def __init__(self, x: int = 0):
|
|
self.x = x
|
|
|
|
|
|
class Bar(_Base):
|
|
def __init__(self, msg: str):
|
|
self.msg = msg
|
|
|
|
|
|
class OptedOut(_Base):
|
|
@classmethod
|
|
def get_name(cls) -> str | None:
|
|
return None
|
|
|
|
|
|
class TestBuildRegistry:
|
|
def test_basic(self):
|
|
registry = build_registry(
|
|
custom_types=[],
|
|
defaults=[Foo, Bar],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
assert set(registry.keys()) == {'Foo', 'Bar'}
|
|
|
|
def test_custom_types(self):
|
|
registry = build_registry(
|
|
custom_types=[Foo],
|
|
defaults=[Bar],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
assert set(registry.keys()) == {'Foo', 'Bar'}
|
|
|
|
def test_custom_overrides_default(self):
|
|
class Foo2(_Base):
|
|
@classmethod
|
|
def get_name(cls) -> str | None:
|
|
return 'Foo'
|
|
|
|
registry = build_registry(
|
|
custom_types=[Foo2],
|
|
defaults=[Foo, Bar],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
assert registry['Foo'] is Foo2
|
|
|
|
def test_duplicate_custom_types(self):
|
|
with pytest.raises(ValueError, match="Duplicate thing class name: 'Foo'"):
|
|
build_registry(
|
|
custom_types=[Foo, Foo],
|
|
defaults=[],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
|
|
def test_opted_out_default(self):
|
|
registry = build_registry(
|
|
custom_types=[],
|
|
defaults=[Foo, OptedOut],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
assert set(registry.keys()) == {'Foo'}
|
|
|
|
def test_opted_out_custom_raises(self):
|
|
with pytest.raises(ValueError, match='has opted out of serialization'):
|
|
build_registry(
|
|
custom_types=[OptedOut],
|
|
defaults=[],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
)
|
|
|
|
def test_validate_callback(self):
|
|
def validate(cls: type[Any]) -> None:
|
|
if cls.__name__ == 'Foo': # pragma: no branch
|
|
raise ValueError('Foo is not allowed')
|
|
|
|
with pytest.raises(ValueError, match='Foo is not allowed'):
|
|
build_registry(
|
|
custom_types=[Foo],
|
|
defaults=[],
|
|
get_name=lambda c: c.get_name(),
|
|
label='thing',
|
|
validate=validate,
|
|
)
|
|
|
|
|
|
class TestLoadFromRegistry:
|
|
def test_success_no_args(self):
|
|
registry: dict[str, type[Any]] = {'Foo': Foo}
|
|
spec = NamedSpec(name='Foo', arguments=None)
|
|
result = load_from_registry(registry, spec, label='thing', custom_types_param='custom_things')
|
|
assert isinstance(result, Foo)
|
|
assert result.x == 0
|
|
|
|
def test_success_with_args(self):
|
|
registry: dict[str, type[Any]] = {'Foo': Foo}
|
|
spec = NamedSpec(name='Foo', arguments=(42,))
|
|
result = load_from_registry(registry, spec, label='thing', custom_types_param='custom_things')
|
|
assert isinstance(result, Foo)
|
|
assert result.x == 42
|
|
|
|
def test_success_with_kwargs(self):
|
|
registry: dict[str, type[Any]] = {'Bar': Bar}
|
|
spec = NamedSpec(name='Bar', arguments={'msg': 'hello'})
|
|
result = load_from_registry(registry, spec, label='thing', custom_types_param='custom_things')
|
|
assert isinstance(result, Bar)
|
|
assert result.msg == 'hello'
|
|
|
|
def test_not_found(self):
|
|
registry: dict[str, type[Any]] = {'Foo': Foo}
|
|
spec = NamedSpec(name='Baz', arguments=None)
|
|
with pytest.raises(ValueError, match="Thing 'Baz' is not in the provided `custom_things`"):
|
|
load_from_registry(registry, spec, label='thing', custom_types_param='custom_things')
|
|
|
|
def test_instantiation_error(self):
|
|
registry: dict[str, type[Any]] = {'Bar': Bar}
|
|
spec = NamedSpec(name='Bar', arguments=None) # Bar requires msg
|
|
with pytest.raises(ValueError, match="Failed to instantiate thing 'Bar'"):
|
|
load_from_registry(registry, spec, label='thing', custom_types_param='custom_things')
|
|
|
|
def test_custom_instantiate(self):
|
|
@dataclass
|
|
class Baz:
|
|
value: str
|
|
|
|
@classmethod
|
|
def create(cls, *args: Any, **kwargs: Any) -> 'Baz':
|
|
return cls(value=f'custom:{args[0] if args else kwargs.get("value", "")}')
|
|
|
|
registry: dict[str, type[Any]] = {'Baz': Baz}
|
|
spec = NamedSpec(name='Baz', arguments=('test',))
|
|
result = load_from_registry(
|
|
registry,
|
|
spec,
|
|
label='thing',
|
|
custom_types_param='custom_things',
|
|
instantiate=lambda cls, args, kwargs: cls.create(*args, **kwargs),
|
|
)
|
|
assert result.value == 'custom:test'
|
|
|
|
|
|
class TestBuildSchemaTypes:
|
|
def test_no_params_class(self):
|
|
class NoParams:
|
|
def __init__(self): ... # pragma: no cover
|
|
|
|
registry = {'NoParams': NoParams}
|
|
types = build_schema_types(registry)
|
|
# Should include the Literal[name] form
|
|
assert len(types) >= 1
|
|
|
|
def test_single_param_class(self):
|
|
class SingleParam:
|
|
def __init__(self, x: int): ... # pragma: no cover
|
|
|
|
registry = {'SingleParam': SingleParam}
|
|
types = build_schema_types(registry)
|
|
assert len(types) >= 1
|
|
|
|
def test_multi_param_class(self):
|
|
class MultiParam:
|
|
def __init__(self, x: int, y: str): ... # pragma: no cover
|
|
|
|
registry = {'MultiParam': MultiParam}
|
|
types = build_schema_types(registry)
|
|
assert len(types) >= 1
|
|
|
|
def test_custom_schema_target(self):
|
|
def my_factory(name: str, count: int = 1) -> None: ... # pragma: no cover
|
|
|
|
registry = {'MyClass': object}
|
|
types = build_schema_types(
|
|
registry,
|
|
get_schema_target=lambda c: my_factory,
|
|
)
|
|
assert len(types) >= 1
|